From 20643f640391180229074c76f9f1fbdd8cb65622 Mon Sep 17 00:00:00 2001
From: Benjamin Davis Install (macOS & Linux)
Windows: use install.ps1
(irm https://davis7dotsh.github.io/open-yt-cli/install.ps1 | iex) or grab a zip from the
releases page.
Prebuilt binaries: Linux and macOS on x86-64 and ARM64, plus Windows x86-64 + (ARM64 Windows runs the x86-64 build under emulation). Each release is a single + self-contained executable — no runtime to install.
# 1. Create a free YouTube Data API v3 key (see the setup guide below)
diff --git a/site/install.ps1 b/site/install.ps1
index 92bbe1b..ad925e5 100644
--- a/site/install.ps1
+++ b/site/install.ps1
@@ -9,17 +9,25 @@
# Downloads the windows zip from GitHub Releases, verifies its SHA-256
# against checksums.txt, and installs oytc.exe plus oytc_update.cmd /
# oytc_upgrade.cmd shims. Never requires administrator rights.
+#
+# Only one Windows build is published: windows/amd64. The toolchain has no
+# native ARM64 Windows target, so ARM64 machines install the amd64 binary and
+# run it under Windows' x64 emulation.
$ErrorActionPreference = 'Stop'
$Repo = 'davis7dotsh/open-yt-cli'
-$arch = switch ((Get-CimInstance Win32_Processor).Architecture) {
- 12 { 'arm64' } # ARM64
- 9 { 'amd64' } # x64
- default {
- if ([Environment]::Is64BitOperatingSystem) { 'amd64' }
- else { throw 'oytc requires a 64-bit Windows (amd64 or arm64).' }
- }
+# The published Windows asset is always amd64; $arch stays a variable so the
+# asset name below keeps the same oytc__windows_.zip shape as
+# the packager and the self-updater.
+$arch = 'amd64'
+$processorArchitecture = @(Get-CimInstance Win32_Processor)[0].Architecture
+if ($processorArchitecture -eq 12) {
+ # ARM64. There is no windows/arm64 asset; Windows on ARM runs x64 binaries
+ # under emulation, so install amd64 rather than failing.
+ Write-Host 'ARM64 Windows detected: installing the amd64 build, which runs under x64 emulation.'
+} elseif ($processorArchitecture -ne 9 -and -not [Environment]::Is64BitOperatingSystem) {
+ throw 'oytc requires a 64-bit Windows (amd64, or arm64 with x64 emulation).'
}
$version = $env:OYTC_VERSION
diff --git a/site/install.sh b/site/install.sh
index 717b5ab..3bffb4c 100755
--- a/site/install.sh
+++ b/site/install.sh
@@ -10,14 +10,15 @@
# OYTC_NO_SYMLINKS set to 1 to skip the oytc_update/oytc_upgrade symlinks
#
# Behavior:
-# - Detects OS (linux, darwin) and architecture (amd64, arm64).
+# - Detects OS (linux, darwin) and architecture (amd64, arm64). All four
+# combinations are published; Windows is served by install.ps1.
# - Downloads the release archive and checksums.txt from GitHub Releases.
# - Verifies the archive's SHA-256 before extracting anything.
# - Installs to a user-writable directory; never requires root by default.
# - Creates oytc_update and oytc_upgrade symlinks (self-update aliases).
#
# Windows users: this script supports macOS and Linux only. On Windows,
-# download the oytc__windows_.zip asset from
+# download the oytc__windows_amd64.zip asset from
# https://github.com/davis7dotsh/open-yt-cli/releases, verify its SHA-256
# against checksums.txt (PowerShell: Get-FileHash -Algorithm SHA256), and
# place oytc.exe on your PATH.
diff --git a/src/cli/root.ts b/src/cli/root.ts
index 4e8a4d3..1d177c6 100644
--- a/src/cli/root.ts
+++ b/src/cli/root.ts
@@ -21,6 +21,7 @@
import { Effect, Layer, Result } from "effect"
import { Command } from "../effect.ts"
import { AppOptions, ProcessEnv } from "../services/index.ts"
+import { ProcessEnvLive } from "../impl/processEnv.ts"
import { globalFlags } from "./flags.ts"
import { resolveGlobals } from "./globals.ts"
@@ -47,6 +48,10 @@ export const root = Command.make("oytc").pipe(
if (Result.isFailure(resolved)) return yield* Effect.fail(resolved.failure)
return resolved.success
})
- )
+ // ProcessEnv is supplied here rather than left to main.ts's
+ // `Effect.provide(AppLayer)`: this layer is constructed by
+ // `Command.provide` during argument parsing, which happens before the
+ // outer provide applies, so the requirement must be discharged locally.
+ ).pipe(Layer.provide(ProcessEnvLive))
)
)
diff --git a/src/domain/errors.test.ts b/src/domain/errors.test.ts
index e7266fe..6619be1 100644
--- a/src/domain/errors.test.ts
+++ b/src/domain/errors.test.ts
@@ -167,7 +167,24 @@ describe("statusText", () => {
expect(statusText(status)).toBe(expected)
})
- test("unrecognised status yields empty string, not an invented phrase", () => {
- expect(statusText(599)).toBe("")
+ // Verified against Go 1.26.5 net/http.StatusText by iterating 100..599.
+ test.each([
+ [402, "Payment Required"],
+ [418, "I'm a teapot"],
+ [451, "Unavailable For Legal Reasons"],
+ [507, "Insufficient Storage"],
+ [511, "Network Authentication Required"],
+ [100, "Continue"],
+ [200, "OK"],
+ [308, "Permanent Redirect"]
+ ])("%d -> %s (full table, not just the common codes)", (status, expected) => {
+ expect(statusText(status)).toBe(expected)
})
+
+ test.each([[599], [509], [512], [0], [99]])(
+ "unrecognised status %d yields empty string, not an invented phrase",
+ (status) => {
+ expect(statusText(status)).toBe("")
+ }
+ )
})
diff --git a/src/domain/errors.ts b/src/domain/errors.ts
index 8eed551..5b52035 100644
--- a/src/domain/errors.ts
+++ b/src/domain/errors.ts
@@ -15,22 +15,77 @@
import { Data, Runtime } from "effect"
-/** HTTP status -> canonical reason phrase, matching Go's http.StatusText. */
+/**
+ * HTTP status -> canonical reason phrase.
+ *
+ * Transcribed verbatim from Go 1.26.5's `net/http.StatusText` (generated by
+ * iterating 100..599). The full table matters: `toApiError` falls back to this
+ * whenever the error envelope carries no message, so a 402/451/507 with an
+ * unparsable body must produce the same user-visible text Go produced.
+ */
const STATUS_TEXT: Readonly> = {
+ 100: "Continue",
+ 101: "Switching Protocols",
+ 102: "Processing",
+ 103: "Early Hints",
+ 200: "OK",
+ 201: "Created",
+ 202: "Accepted",
+ 203: "Non-Authoritative Information",
+ 204: "No Content",
+ 205: "Reset Content",
+ 206: "Partial Content",
+ 207: "Multi-Status",
+ 208: "Already Reported",
+ 226: "IM Used",
+ 300: "Multiple Choices",
+ 301: "Moved Permanently",
+ 302: "Found",
+ 303: "See Other",
+ 304: "Not Modified",
+ 305: "Use Proxy",
+ 307: "Temporary Redirect",
+ 308: "Permanent Redirect",
400: "Bad Request",
401: "Unauthorized",
+ 402: "Payment Required",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
+ 406: "Not Acceptable",
+ 407: "Proxy Authentication Required",
408: "Request Timeout",
409: "Conflict",
410: "Gone",
+ 411: "Length Required",
+ 412: "Precondition Failed",
+ 413: "Request Entity Too Large",
+ 414: "Request URI Too Long",
+ 415: "Unsupported Media Type",
+ 416: "Requested Range Not Satisfiable",
+ 417: "Expectation Failed",
+ 418: "I'm a teapot",
+ 421: "Misdirected Request",
+ 422: "Unprocessable Entity",
+ 423: "Locked",
+ 424: "Failed Dependency",
+ 425: "Too Early",
+ 426: "Upgrade Required",
+ 428: "Precondition Required",
429: "Too Many Requests",
+ 431: "Request Header Fields Too Large",
+ 451: "Unavailable For Legal Reasons",
500: "Internal Server Error",
501: "Not Implemented",
502: "Bad Gateway",
503: "Service Unavailable",
- 504: "Gateway Timeout"
+ 504: "Gateway Timeout",
+ 505: "HTTP Version Not Supported",
+ 506: "Variant Also Negotiates",
+ 507: "Insufficient Storage",
+ 508: "Loop Detected",
+ 510: "Not Extended",
+ 511: "Network Authentication Required"
}
/** Go's http.StatusText returns "" for unrecognised codes; do not invent one. */
diff --git a/src/impl/analyticsApi.test.ts b/src/impl/analyticsApi.test.ts
new file mode 100644
index 0000000..b8feaec
--- /dev/null
+++ b/src/impl/analyticsApi.test.ts
@@ -0,0 +1,680 @@
+/**
+ * Ported from `internal/analytics/client_test.go` (both cases) plus the
+ * boundary conditions the Go tests leave implicit.
+ *
+ * `HttpCore` is consumed by TAG ONLY — every test here runs against a
+ * `Layer.succeed`/`Layer.mock` double, never the live transport.
+ */
+
+import { describe, expect, test } from "bun:test"
+import { Effect, Layer, Result, Schema } from "effect"
+import { encodeListResultJson } from "../domain/listResult.ts"
+import { encodeGoValue } from "../json/encode.ts"
+import { parseJson } from "../json/parse.ts"
+import { isRawNumber, type JsonObject, type JsonValue, rawLiteral, rawNumber } from "../json/value.ts"
+import { AnalyticsResponse } from "../schema/analytics.ts"
+import {
+ type AnalyticsQuery,
+ HttpCore,
+ type HttpCoreRequest,
+ type HttpCoreShape,
+ type Params
+} from "../services/index.ts"
+import {
+ addUtcDays,
+ ANALYTICS_BASE_URL,
+ ANALYTICS_IDS,
+ analyticsListResult,
+ analyticsQueryParams,
+ defaultDateRange,
+ formatDateOnly,
+ makeAnalyticsApiWith,
+ MAX_RESULTS,
+ normalizeAnalytics,
+ resolveLimit,
+ resolveStartIndex,
+ statusCheckDateRange,
+ tolerateNullSlices
+} from "./analyticsApi.ts"
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const baseQuery: AnalyticsQuery = {
+ metrics: "views",
+ dimensions: "",
+ filters: "",
+ sort: "",
+ startDate: "2026-01-01",
+ endDate: "2026-01-02",
+ limit: 0,
+ startIndex: 0
+}
+
+const query = (overrides: Partial = {}): AnalyticsQuery => ({
+ ...baseQuery,
+ ...overrides
+})
+
+const paramsOf = (q: AnalyticsQuery): Params => {
+ const r = analyticsQueryParams(q)
+ if (Result.isFailure(r)) throw new Error(`unexpected failure: ${r.failure.message}`)
+ return r.success
+}
+
+const failureOf = (q: AnalyticsQuery): string => {
+ const r = analyticsQueryParams(q)
+ if (Result.isSuccess(r)) throw new Error("expected a failure")
+ return r.failure.message
+}
+
+const asRecord = (params: Params): Record =>
+ Object.fromEntries(params.map(([k, v]) => [k, v]))
+
+const keysOf = (params: Params): ReadonlyArray => params.map(([k]) => k)
+
+const decodeSync = Schema.decodeUnknownSync(AnalyticsResponse)
+
+const parseOk = (text: string): JsonValue => {
+ const r = parseJson(text)
+ if (Result.isFailure(r)) throw new Error(`parse failed: ${r.failure.message}`)
+ return r.success
+}
+
+/** A recording HttpCore double: captures the request, replays a fixed body. */
+interface Recorder {
+ readonly layer: Layer.Layer
+ readonly requests: Array
+}
+
+const recordingHttpCore = (body: JsonValue): Recorder => {
+ const requests: Array = []
+ const shape: HttpCoreShape = {
+ getJson: (request) =>
+ Effect.sync(() => {
+ requests.push(request)
+ return body
+ })
+ }
+ return { layer: Layer.succeed(HttpCore, shape), requests }
+}
+
+const BASE = "http://127.0.0.1:1/youtubeanalytics/v2"
+
+const runReport = (
+ q: AnalyticsQuery,
+ recorder: Recorder,
+ baseUrl = BASE
+): Promise =>
+ Effect.runPromise(
+ Effect.gen(function* () {
+ const api = yield* makeAnalyticsApiWith(baseUrl)
+ return yield* api.report(q)
+ }).pipe(Effect.provide(recorder.layer))
+ )
+
+// ---------------------------------------------------------------------------
+// TestReportNormalizesRowsByColumnName (client_test.go)
+// ---------------------------------------------------------------------------
+
+const GO_TEST_BODY =
+ '{"columnHeaders":[{"name":"day","columnType":"DIMENSION","dataType":"STRING"},' +
+ '{"name":"views","columnType":"METRIC","dataType":"INTEGER"},' +
+ '{"name":"estimatedMinutesWatched","columnType":"METRIC","dataType":"FLOAT"}],' +
+ '"rows":[["2026-01-01",12,3.5],["2026-01-02",8,2.25]]}'
+
+describe("report normalizes rows by column name", () => {
+ const goQuery = query({
+ startDate: "2026-01-01",
+ endDate: "2026-01-02",
+ metrics: "views,estimatedMinutesWatched",
+ dimensions: "day",
+ limit: 25
+ })
+
+ test("hits the reports resource on the analytics base, authenticated", async () => {
+ const recorder = recordingHttpCore(parseOk(GO_TEST_BODY))
+ await runReport(goQuery, recorder)
+ expect(recorder.requests).toHaveLength(1)
+ const request = recorder.requests[0]!
+ expect(request.baseUrl).toBe(BASE)
+ expect(request.resource).toBe("reports")
+ // Analytics is OAuth-only; the transport attaches Bearer, never a key.
+ expect(request.authenticate).toBe(true)
+ })
+
+ test("sends the exact query the Go test asserts", async () => {
+ const recorder = recordingHttpCore(parseOk(GO_TEST_BODY))
+ await runReport(goQuery, recorder)
+ const params = asRecord(recorder.requests[0]!.params)
+ expect(params["ids"]).toBe("channel==MINE")
+ expect(params["metrics"]).toBe("views,estimatedMinutesWatched")
+ expect(params["dimensions"]).toBe("day")
+ expect(params["maxResults"]).toBe("25")
+ expect(params["startIndex"]).toBe("1")
+ })
+
+ test("flattens two rows and reports exactly one request", async () => {
+ const recorder = recordingHttpCore(parseOk(GO_TEST_BODY))
+ const response = await runReport(goQuery, recorder)
+ const result = analyticsListResult(response)
+ expect(result.items).toHaveLength(2)
+ expect(result.requests).toBe(1)
+ expect(result.nextPageToken).toBe("")
+ expect(result.items[0]!["day"]).toBe("2026-01-01")
+ expect(result.items[1]!["day"]).toBe("2026-01-02")
+ })
+
+ // The heart of the Go assertion: `views` must be json.Number("12"), not a
+ // float64 that renders as 12.0.
+ test("integer cell 12 stays the literal \"12\", never \"12.0\"", async () => {
+ const recorder = recordingHttpCore(parseOk(GO_TEST_BODY))
+ const response = await runReport(goQuery, recorder)
+ const first = normalizeAnalytics(response)[0]!
+ const views = first["views"]
+ expect(isRawNumber(views)).toBe(true)
+ expect(rawLiteral(views as { readonly $rawNumber: string })).toBe("12")
+ expect(encodeGoValue(views as JsonValue, { indent: "" })).toBe("12")
+ expect(encodeGoValue(first, { indent: "" })).toBe(
+ '{"day":"2026-01-01","estimatedMinutesWatched":3.5,"views":12}'
+ )
+ })
+
+ test("float cells keep their literals too", async () => {
+ const recorder = recordingHttpCore(parseOk(GO_TEST_BODY))
+ const response = await runReport(goQuery, recorder)
+ const items = normalizeAnalytics(response)
+ expect(rawLiteral(items[0]!["estimatedMinutesWatched"] as never)).toBe("3.5")
+ expect(rawLiteral(items[1]!["estimatedMinutesWatched"] as never)).toBe("2.25")
+ })
+
+ test("counters beyond 2^53 survive byte-for-byte", async () => {
+ const recorder = recordingHttpCore(
+ parseOk(
+ '{"columnHeaders":[{"name":"views"}],"rows":[[9007199254740993123]]}'
+ )
+ )
+ const response = await runReport(query(), recorder)
+ const item = normalizeAnalytics(response)[0]!
+ expect(encodeGoValue(item, { indent: "" })).toBe('{"views":9007199254740993123}')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestNormalizeFillsMissingCells (client_test.go)
+// ---------------------------------------------------------------------------
+
+describe("normalize", () => {
+ test("pads a short row with explicit null", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "day" }, { name: "views" }],
+ rows: [["2026-01-01"]]
+ })
+ expect(items).toHaveLength(1)
+ expect(items[0]!["day"]).toBe("2026-01-01")
+ expect(items[0]!["views"]).toBeNull()
+ })
+
+ test("the padded key is PRESENT, not absent", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "day" }, { name: "views" }],
+ rows: [["2026-01-01"]]
+ })
+ expect(Object.keys(items[0] as JsonObject)).toEqual(["day", "views"])
+ expect("views" in (items[0] as object)).toBe(true)
+ expect(encodeGoValue(items[0]!, { indent: "" })).toBe('{"day":"2026-01-01","views":null}')
+ })
+
+ test("drops cells beyond the header list", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "day" }],
+ rows: [["2026-01-01", "extra", rawNumber("7")]]
+ })
+ expect(Object.keys(items[0] as JsonObject)).toEqual(["day"])
+ expect(items[0]!["day"]).toBe("2026-01-01")
+ })
+
+ test("an empty row becomes an all-null object", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "a" }, { name: "b" }],
+ rows: [[]]
+ })
+ expect(items[0]).toEqual({ a: null, b: null })
+ })
+
+ test("no headers yields empty objects, one per row", () => {
+ const items = normalizeAnalytics({ columnHeaders: [], rows: [["x"], ["y"]] })
+ expect(items).toEqual([{}, {}])
+ })
+
+ test("no rows yields an empty array, never null", () => {
+ const items = normalizeAnalytics({ columnHeaders: [{ name: "day" }], rows: [] })
+ expect(items).toEqual([])
+ })
+
+ test("omitted columnHeaders/rows are treated as empty", () => {
+ expect(normalizeAnalytics({})).toEqual([])
+ expect(normalizeAnalytics({ rows: [["x"]] })).toEqual([{}])
+ expect(normalizeAnalytics({ columnHeaders: [{ name: "day" }] })).toEqual([])
+ })
+
+ // Go writes into a map, so a repeated header name keeps the LAST cell.
+ test("duplicate header names collapse, last write winning", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "day" }, { name: "day" }],
+ rows: [["first", "second"]]
+ })
+ expect(items[0]).toEqual({ day: "second" })
+ })
+
+ // Go stores `__proto__` in its map like any other header name. A plain
+ // `item[name] = value` in JS hits the Object.prototype setter instead, so
+ // the column would silently vanish from the output.
+ test("a header named __proto__ becomes a real column, not a prototype write", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "__proto__" }, { name: "views" }],
+ rows: [["2026-01-01", rawNumber("5")]]
+ })
+ expect(Object.keys(items[0] as JsonObject)).toEqual(["__proto__", "views"])
+ expect(Object.getPrototypeOf(items[0])).toBe(Object.prototype)
+ expect(encodeGoValue(items[0]!, { indent: "" })).toBe(
+ '{"__proto__":"2026-01-01","views":5}'
+ )
+ })
+
+ // The dangerous shape: an OBJECT cell under a __proto__ header would
+ // otherwise replace the row's prototype and export zero own keys.
+ test("an object cell under a __proto__ header does not swap the prototype", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "__proto__" }],
+ rows: [[{ evil: "yes" }]]
+ })
+ expect(Object.getPrototypeOf(items[0])).toBe(Object.prototype)
+ expect((items[0] as Record)["evil"]).toBeUndefined()
+ expect(encodeGoValue(items[0]!, { indent: "" })).toBe('{"__proto__":{"evil":"yes"}}')
+ })
+
+ test("duplicate __proto__ headers still collapse last-write-wins", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "__proto__" }, { name: "__proto__" }],
+ rows: [["first", "second"]]
+ })
+ expect(items[0]!["__proto__"]).toBe("second")
+ expect(Object.keys(items[0] as JsonObject)).toEqual(["__proto__"])
+ })
+
+ test("null, boolean and nested cells pass through untouched", () => {
+ const items = normalizeAnalytics({
+ columnHeaders: [{ name: "a" }, { name: "b" }, { name: "c" }],
+ rows: [[null, true, ["x", "y"]]]
+ })
+ expect(items[0]!["a"]).toBeNull()
+ expect(items[0]!["b"]).toBe(true)
+ expect(items[0]!["c"]).toEqual(["x", "y"])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Parameter construction
+// ---------------------------------------------------------------------------
+
+describe("query parameters", () => {
+ test("ids is the hard-coded constant", () => {
+ expect(ANALYTICS_IDS).toBe("channel==MINE")
+ expect(asRecord(paramsOf(query()))["ids"]).toBe("channel==MINE")
+ })
+
+ test("the six unconditional params are always present", () => {
+ expect([...keysOf(paramsOf(query()))].sort()).toEqual([
+ "endDate",
+ "ids",
+ "maxResults",
+ "metrics",
+ "startDate",
+ "startIndex"
+ ])
+ })
+
+ // Go sets startDate/endDate unconditionally; empty dates go on the wire and
+ // Google answers 400. Preserved deliberately — see SPEC_API.md §6.3.
+ test("empty dates are still sent, as empty values", () => {
+ const params = paramsOf(query({ startDate: "", endDate: "" }))
+ expect(keysOf(params)).toContain("startDate")
+ expect(keysOf(params)).toContain("endDate")
+ const record = asRecord(params)
+ expect(record["startDate"]).toBe("")
+ expect(record["endDate"]).toBe("")
+ })
+
+ test.each([
+ ["dimensions", "day"],
+ ["filters", "video==abc"],
+ ["sort", "-views"]
+ ])("%s is omitted when empty and sent when set", (key, value) => {
+ expect(keysOf(paramsOf(query()))).not.toContain(key)
+ const params = paramsOf(query({ [key]: value } as Partial))
+ expect(asRecord(params)[key]).toBe(value)
+ })
+
+ test("all three optional params can appear together", () => {
+ const params = asRecord(
+ paramsOf(query({ dimensions: "ageGroup,gender", filters: "video==abc", sort: "-views" }))
+ )
+ expect(params["dimensions"]).toBe("ageGroup,gender")
+ expect(params["filters"]).toBe("video==abc")
+ expect(params["sort"]).toBe("-views")
+ })
+
+ test("metrics is passed through verbatim", () => {
+ expect(asRecord(paramsOf(query({ metrics: "views,likes" })))["metrics"]).toBe("views,likes")
+ })
+
+ test("empty metrics is rejected", () => {
+ expect(failureOf(query({ metrics: "" }))).toBe("analytics metrics cannot be empty")
+ })
+
+ test("metrics is checked before the limit bounds", () => {
+ expect(failureOf(query({ metrics: "", limit: 999 }))).toBe(
+ "analytics metrics cannot be empty"
+ )
+ })
+
+ test("report fails without ever reaching the transport", async () => {
+ const recorder = recordingHttpCore(parseOk("{}"))
+ const exit = await Effect.runPromise(
+ Effect.exit(
+ Effect.gen(function* () {
+ const api = yield* makeAnalyticsApiWith(BASE)
+ return yield* api.report(query({ metrics: "" }))
+ }).pipe(Effect.provide(recorder.layer))
+ )
+ )
+ expect(exit._tag).toBe("Failure")
+ expect(recorder.requests).toHaveLength(0)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Limit and startIndex resolution — the zero-default-before-range-check rule
+// ---------------------------------------------------------------------------
+
+describe("limit resolution", () => {
+ test("MaxResults is 200", () => {
+ expect(MAX_RESULTS).toBe(200)
+ })
+
+ test.each([
+ [0, 200, "zero means unspecified, resolved BEFORE the range check"],
+ [1, 1, "lower bound"],
+ [25, 25, "the Go test's value"],
+ [200, 200, "upper bound"]
+ ])("limit %d -> maxResults %d (%s)", (input, expected) => {
+ const r = resolveLimit(input)
+ if (!Result.isSuccess(r)) throw new Error("expected a success")
+ expect(r.success).toBe(expected)
+ expect(asRecord(paramsOf(query({ limit: input })))["maxResults"]).toBe(String(expected))
+ })
+
+ test.each([
+ [201, "just past the upper bound"],
+ [1000, "far past the upper bound"],
+ [-1, "negative, NOT rescued by the zero-default"],
+ [-200, "negative magnitude is irrelevant"]
+ ])("limit %d is rejected (%s)", (input) => {
+ expect(failureOf(query({ limit: input }))).toBe("analytics limit must be between 1 and 200")
+ })
+
+ // Order matters: if the range check ran first, 0 would be an error too.
+ test("0 is valid but -1 is not — proof the zero-default runs first", () => {
+ expect(Result.isSuccess(resolveLimit(0))).toBe(true)
+ expect(Result.isFailure(resolveLimit(-1))).toBe(true)
+ })
+})
+
+describe("startIndex resolution", () => {
+ test("0 becomes 1", () => {
+ expect(resolveStartIndex(0)).toBe(1)
+ expect(asRecord(paramsOf(query({ startIndex: 0 })))["startIndex"]).toBe("1")
+ })
+
+ test.each([1, 2, 201, 100_000])("%d passes through unchanged", (input) => {
+ expect(resolveStartIndex(input)).toBe(input)
+ expect(asRecord(paramsOf(query({ startIndex: input })))["startIndex"]).toBe(String(input))
+ })
+
+ // Go range-checks startIndex nowhere; a negative is sent verbatim.
+ test("negatives are sent verbatim, not validated", () => {
+ expect(resolveStartIndex(-5)).toBe(-5)
+ expect(asRecord(paramsOf(query({ startIndex: -5 })))["startIndex"]).toBe("-5")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Envelope
+// ---------------------------------------------------------------------------
+
+describe("list envelope", () => {
+ test("requests is always exactly 1", () => {
+ expect(analyticsListResult({}).requests).toBe(1)
+ expect(analyticsListResult({ columnHeaders: [], rows: [] }).requests).toBe(1)
+ expect(
+ analyticsListResult({ columnHeaders: [{ name: "a" }], rows: [["x"], ["y"]] }).requests
+ ).toBe(1)
+ })
+
+ test("nextPageToken is always empty — no analytics pagination", () => {
+ expect(analyticsListResult({ columnHeaders: [{ name: "a" }], rows: [["x"]] }).nextPageToken)
+ .toBe("")
+ })
+
+ test("an empty report yields an empty items array", () => {
+ expect(analyticsListResult({}).items).toEqual([])
+ })
+
+ // Golden: byte-for-byte output of the equivalent Go program (`go run`,
+ // json.Encoder with SetIndent("", " ") over the same ListResult struct).
+ test.each([
+ [
+ '{"columnHeaders":[{"name":"day"},{"name":"views"}],"rows":[["2026-01-01",12],["2026-01-02",8]]}',
+ '{\n "items": [\n {\n "day": "2026-01-01",\n "views": 12\n },\n' +
+ ' {\n "day": "2026-01-02",\n "views": 8\n }\n ],\n "requests": 1\n}\n'
+ ],
+ ['{"columnHeaders":[{"name":"day"}],"rows":[]}', '{\n "items": [],\n "requests": 1\n}\n'],
+ ["{}", '{\n "items": [],\n "requests": 1\n}\n']
+ ])("envelope for %s matches the Go encoder byte-for-byte", (body, expected) => {
+ expect(encodeListResultJson(analyticsListResult(decodeSync(parseOk(body))))).toBe(expected)
+ })
+
+ // nextPageToken carries `omitempty` in Go and is never set by analytics, so
+ // the key must not appear at all.
+ test("the envelope omits nextPageToken entirely", () => {
+ expect(
+ encodeListResultJson(analyticsListResult({ columnHeaders: [{ name: "a" }], rows: [["x"]] }))
+ ).not.toContain("nextPageToken")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Transport surface
+// ---------------------------------------------------------------------------
+
+describe("transport", () => {
+ test("the default base URL is the analytics v2 host", () => {
+ expect(ANALYTICS_BASE_URL).toBe("https://youtubeanalytics.googleapis.com/v2")
+ })
+
+ test("the base URL is injectable, so tests can point at a local server", async () => {
+ const recorder = recordingHttpCore(parseOk("{}"))
+ await runReport(query(), recorder, "http://localhost:9/v2")
+ expect(recorder.requests[0]!.baseUrl).toBe("http://localhost:9/v2")
+ })
+
+ test("exactly one request per report — no token loop", async () => {
+ const recorder = recordingHttpCore(parseOk(GO_TEST_BODY))
+ await runReport(query(), recorder)
+ expect(recorder.requests).toHaveLength(1)
+ })
+
+ test("transport failures propagate untouched", async () => {
+ const failing = Layer.succeed(HttpCore, {
+ getJson: () => Effect.die("boom")
+ } satisfies HttpCoreShape)
+ const exit = await Effect.runPromise(
+ Effect.exit(
+ Effect.gen(function* () {
+ const api = yield* makeAnalyticsApiWith(BASE)
+ return yield* api.report(query())
+ }).pipe(Effect.provide(failing))
+ )
+ )
+ expect(exit._tag).toBe("Failure")
+ })
+
+ test("a response with unknown extra keys still decodes", async () => {
+ const recorder = recordingHttpCore(
+ parseOk('{"kind":"youtubeAnalytics#resultTable","columnHeaders":[{"name":"day"}],"rows":[["x"]]}')
+ )
+ const response = await runReport(query(), recorder)
+ expect(normalizeAnalytics(response)).toEqual([{ day: "x" }])
+ })
+
+ test("an empty JSON object decodes to an empty report", async () => {
+ const recorder = recordingHttpCore(parseOk("{}"))
+ const response = await runReport(query(), recorder)
+ expect(analyticsListResult(response).items).toEqual([])
+ })
+
+ // Go's encoding/json unmarshals a JSON null into a slice field as a nil
+ // slice with NO error, so these are valid empty reports there. Verified
+ // against Go 1.26.5; the frozen schema would reject the explicit null, so
+ // the client strips it first.
+ test.each([
+ '{"rows":null}',
+ '{"columnHeaders":null}',
+ '{"columnHeaders":null,"rows":null}',
+ '{"columnHeaders":[{"name":"day"}],"rows":null}'
+ ])("%s decodes to an empty report, matching Go", async (body) => {
+ const recorder = recordingHttpCore(parseOk(body))
+ const response = await runReport(query(), recorder)
+ expect(analyticsListResult(response).items).toEqual([])
+ })
+
+ test("a null row list does not disturb the headers", async () => {
+ const recorder = recordingHttpCore(parseOk('{"columnHeaders":[{"name":"day"}],"rows":null}'))
+ const response = await runReport(query(), recorder)
+ expect(response.columnHeaders).toEqual([{ name: "day" }])
+ })
+
+ test("tolerateNullSlices leaves everything else untouched", () => {
+ const body = parseOk('{"kind":"x","columnHeaders":[{"name":"day"}],"rows":[["x"]]}')
+ expect(tolerateNullSlices(body)).toBe(body)
+ expect(tolerateNullSlices(parseOk('{"other":null}'))).toEqual({ other: null })
+ expect(tolerateNullSlices(parseOk("[1,2]"))).toEqual([rawNumber("1"), rawNumber("2")])
+ })
+
+ // Go: fmt.Errorf("decode YouTube API response: %w", err) — an OperationalError,
+ // which exits 6.
+ test.each([
+ ['{"rows":"nope"}', "rows is not an array"],
+ ['{"columnHeaders":[{"name":123}]}', "header name is not a string"],
+ ["[1,2,3]", "body is not an object"]
+ ])("%s fails to decode (%s)", async (body) => {
+ const recorder = recordingHttpCore(parseOk(body))
+ const exit = await Effect.runPromise(
+ Effect.exit(
+ Effect.gen(function* () {
+ const api = yield* makeAnalyticsApiWith(BASE)
+ return yield* api.report(query())
+ }).pipe(Effect.provide(recorder.layer))
+ )
+ )
+ expect(exit._tag).toBe("Failure")
+ })
+
+ test("a decode failure is an OperationalError with Go's message prefix", async () => {
+ const recorder = recordingHttpCore(parseOk('{"rows":"nope"}'))
+ const result = await Effect.runPromise(
+ Effect.result(
+ Effect.gen(function* () {
+ const api = yield* makeAnalyticsApiWith(BASE)
+ return yield* api.report(query())
+ }).pipe(Effect.provide(recorder.layer))
+ )
+ )
+ if (!Result.isFailure(result)) throw new Error("expected a failure")
+ expect(result.failure._tag).toBe("OperationalError")
+ expect(result.failure.message).toStartWith("decode YouTube API response: ")
+ })
+
+ test("normalize is exposed on the service", async () => {
+ const recorder = recordingHttpCore(parseOk("{}"))
+ const items = await Effect.runPromise(
+ Effect.gen(function* () {
+ const api = yield* makeAnalyticsApiWith(BASE)
+ return api.normalize({ columnHeaders: [{ name: "day" }], rows: [["x"]] })
+ }).pipe(Effect.provide(recorder.layer))
+ )
+ expect(items).toEqual([{ day: "x" }])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Date ranges — UTC, always
+// ---------------------------------------------------------------------------
+
+describe("date helpers", () => {
+ test.each([
+ ["2026-03-01T05:00:00Z", "2026-02-01", "2026-02-28"],
+ ["2026-01-01T00:00:00Z", "2025-12-04", "2025-12-31"],
+ ["2024-03-01T23:59:59Z", "2024-02-02", "2024-02-29"],
+ ["2026-07-24T12:00:00Z", "2026-06-26", "2026-07-23"],
+ ["2025-01-15T00:00:00Z", "2024-12-18", "2025-01-14"]
+ ])("default range at %s is %s..%s", (now, start, end) => {
+ // Values produced by `go run` against the Go expression
+ // end := now().UTC().AddDate(0,0,-1); start := end.AddDate(0,0,-27)
+ expect(defaultDateRange(new Date(now))).toEqual({ start, end })
+ })
+
+ test("the window is 28 inclusive days: end - 27, not end - 28", () => {
+ const { start, end } = defaultDateRange(new Date("2026-07-24T12:00:00Z"))
+ const days =
+ (Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000
+ expect(days).toBe(27)
+ })
+
+ test("today is excluded — end is yesterday", () => {
+ expect(defaultDateRange(new Date("2026-07-24T00:00:00Z")).end).toBe("2026-07-23")
+ })
+
+ // A local-time computation would give a different answer either side of
+ // midnight UTC; this pins the UTC reading.
+ test("computed in UTC, not local time", () => {
+ const justAfterUtcMidnight = new Date("2026-07-24T00:30:00Z")
+ const justBeforeUtcMidnight = new Date("2026-07-23T23:30:00Z")
+ expect(defaultDateRange(justAfterUtcMidnight).end).toBe("2026-07-23")
+ expect(defaultDateRange(justBeforeUtcMidnight).end).toBe("2026-07-22")
+ })
+
+ test("status --check probes a 7-day window that INCLUDES today", () => {
+ expect(statusCheckDateRange(new Date("2026-07-24T12:00:00Z"))).toEqual({
+ start: "2026-07-17",
+ end: "2026-07-24"
+ })
+ })
+
+ test.each([
+ ["2026-07-24T12:00:00Z", "2026-07-24"],
+ ["0999-01-02T00:00:00Z", "0999-01-02"],
+ ["2026-01-09T00:00:00Z", "2026-01-09"]
+ ])("formatDateOnly(%s) = %s (zero-padded, Go's time.DateOnly)", (input, expected) => {
+ expect(formatDateOnly(new Date(input))).toBe(expected)
+ })
+
+ test("addUtcDays rolls over months, years and leap days", () => {
+ expect(formatDateOnly(addUtcDays(new Date("2026-01-31T00:00:00Z"), 1))).toBe("2026-02-01")
+ expect(formatDateOnly(addUtcDays(new Date("2026-01-01T00:00:00Z"), -1))).toBe("2025-12-31")
+ expect(formatDateOnly(addUtcDays(new Date("2024-02-28T00:00:00Z"), 1))).toBe("2024-02-29")
+ expect(formatDateOnly(addUtcDays(new Date("2025-02-28T00:00:00Z"), 1))).toBe("2025-03-01")
+ })
+})
diff --git a/src/impl/analyticsApi.ts b/src/impl/analyticsApi.ts
index ca14f9a..48577d0 100644
--- a/src/impl/analyticsApi.ts
+++ b/src/impl/analyticsApi.ts
@@ -1,2 +1,267 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * YouTube Analytics reports client — the port of `internal/analytics/client.go`.
+ *
+ * One endpoint, no pagination, no token loop: `GET {base}/reports` decoded into
+ * the typed `columnHeaders`/`rows` shape and flattened into row objects.
+ *
+ * Analytics is **OAuth-only**. The Go constructor builds its transport with an
+ * empty API key, so the API-key branch of the auth switch can never fire; here
+ * that falls out of `authenticate: true` plus a `HttpCore` whose token source
+ * strictly beats the key. Retries, the 401-refresh-once dance, the 16 MiB cap,
+ * number-preserving decoding, and `ApiError` construction all live in
+ * `HttpCore` and are inherited unchanged.
+ *
+ * This module depends on `HttpCore` BY TAG ONLY.
+ */
+
+import { Effect, Layer, Result, Schema } from "effect"
+import { OperationalError, type OytcError } from "../domain/errors.ts"
+import type { ListResult } from "../domain/listResult.ts"
+import type { JsonObject, JsonValue } from "../json/value.ts"
+import { AnalyticsResponse } from "../schema/analytics.ts"
+import {
+ AnalyticsApi,
+ type AnalyticsApiShape,
+ type AnalyticsQuery,
+ HttpCore,
+ type HttpCoreShape,
+ type Params
+} from "../services/index.ts"
+
+/** `analytics.DefaultBaseURL`. */
+export const ANALYTICS_BASE_URL = "https://youtubeanalytics.googleapis.com/v2"
+
+/** `analytics.MaxResults`. Also the CLI's `--limit` upper bound. */
+export const MAX_RESULTS = 200
+
+/**
+ * `ids` is a hard-coded literal in Go — there is no support for
+ * `contentOwner==`, `channel==`, or any other owner form.
+ */
+export const ANALYTICS_IDS = "channel==MINE"
+
+const RESOURCE = "reports"
+
+// ---------------------------------------------------------------------------
+// Query resolution
+// ---------------------------------------------------------------------------
+
+/**
+ * Go's exact order of operations:
+ *
+ * limit := query.Limit
+ * if limit == 0 { limit = MaxResults } // zero-default FIRST
+ * if limit < 1 || limit > MaxResults { err } // range check SECOND
+ *
+ * so `0` is legal and means 200, while `-1` and `201` are both errors.
+ */
+export const resolveLimit = (limit: number): Result.Result => {
+ const resolved = limit === 0 ? MAX_RESULTS : limit
+ if (resolved < 1 || resolved > MAX_RESULTS) {
+ return Result.fail(
+ new OperationalError({ message: `analytics limit must be between 1 and ${MAX_RESULTS}` })
+ )
+ }
+ return Result.succeed(resolved)
+}
+
+/**
+ * `0` means "unspecified" and becomes the 1-based first row. Everything else,
+ * negatives included, is sent verbatim — Go range-checks this value nowhere.
+ */
+export const resolveStartIndex = (startIndex: number): number =>
+ startIndex === 0 ? 1 : startIndex
+
+/**
+ * The `reports` query string.
+ *
+ * `ids`, `startDate`, `endDate`, `metrics`, `maxResults` and `startIndex` are
+ * ALWAYS present — including `startDate=&endDate=` when the caller left the
+ * dates empty, which Google answers with a 400. That is Go's behaviour and it
+ * is preserved deliberately; the CLI always fills the dates from computed flag
+ * defaults, so an empty date only reaches here through a direct library call.
+ *
+ * `dimensions`, `filters` and `sort` are sent only when non-empty.
+ */
+export const analyticsQueryParams = (
+ query: AnalyticsQuery
+): Result.Result => {
+ if (query.metrics === "") {
+ return Result.fail(new OperationalError({ message: "analytics metrics cannot be empty" }))
+ }
+ const limit = resolveLimit(query.limit)
+ if (Result.isFailure(limit)) return Result.fail(limit.failure)
+ const startIndex = resolveStartIndex(query.startIndex)
+
+ const params: Array = [
+ ["ids", ANALYTICS_IDS],
+ ["startDate", query.startDate],
+ ["endDate", query.endDate],
+ ["metrics", query.metrics],
+ ["maxResults", String(limit.success)],
+ ["startIndex", String(startIndex)]
+ ]
+ if (query.dimensions !== "") params.push(["dimensions", query.dimensions])
+ if (query.filters !== "") params.push(["filters", query.filters])
+ if (query.sort !== "") params.push(["sort", query.sort])
+ return Result.succeed(params)
+}
+
+// ---------------------------------------------------------------------------
+// Normalization
+// ---------------------------------------------------------------------------
+
+/**
+ * `analytics.Normalize` — flatten `columnHeaders` + `rows` into row objects.
+ *
+ * - a row SHORTER than the header list is padded with explicit `null`
+ * (Go writes the nil into the map; the key is present, not absent)
+ * - a row LONGER than the header list has its extra cells dropped
+ * (the loop is over headers, not over cells)
+ * - duplicate header names collapse, last write winning, as Go's map does
+ *
+ * Cell values arrive as `JsonValue`, so a numeric cell is a `RawNumber` holding
+ * its original literal: the integer `12` re-encodes as `12`, never `12.0`.
+ *
+ * Keys are installed with `Object.defineProperty`, not `item[name] = …`. A
+ * header literally named `__proto__` — which Go stores in its map like any
+ * other string — would otherwise hit the `Object.prototype` setter: the column
+ * would VANISH from the output (or, for an object-valued cell, silently
+ * replace the row's prototype). `defineProperty` creates a plain own property
+ * for every name, so `__proto__` round-trips as a column like Go's does.
+ */
+export const normalizeAnalytics = (
+ response: AnalyticsResponse
+): ReadonlyArray => {
+ const headers = response.columnHeaders ?? []
+ const rows = response.rows ?? []
+ return rows.map((row) => {
+ const item: Record = {}
+ for (let index = 0; index < headers.length; index++) {
+ const name = headers[index]!.name
+ Object.defineProperty(item, name, {
+ value: index < row.length ? (row[index] as JsonValue) : null,
+ enumerable: true,
+ writable: true,
+ configurable: true
+ })
+ }
+ return item
+ })
+}
+
+/**
+ * The list envelope Go's `Report` returns. `requests` is always exactly 1 —
+ * there is no second request and no token pagination — and `nextPageToken` is
+ * always empty, so the JSON envelope omits it.
+ */
+export const analyticsListResult = (response: AnalyticsResponse): ListResult => ({
+ items: normalizeAnalytics(response),
+ nextPageToken: "",
+ requests: 1
+})
+
+// ---------------------------------------------------------------------------
+// Date ranges
+// ---------------------------------------------------------------------------
+
+const pad = (value: number, width: number): string => String(value).padStart(width, "0")
+
+/** Go's `time.DateOnly` (`2006-01-02`) formatting of a UTC instant. */
+export const formatDateOnly = (date: Date): string =>
+ `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(date.getUTCDate(), 2)}`
+
+/** `AddDate(0, 0, days)` in UTC. `Date.UTC` normalizes month/year rollover. */
+export const addUtcDays = (date: Date, days: number): Date =>
+ new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days))
+
+export interface DateRange {
+ readonly start: string
+ readonly end: string
+}
+
+/**
+ * The `--start`/`--end` flag defaults: 28 complete UTC days ending YESTERDAY.
+ *
+ * end = todayUTC - 1 day
+ * start = end - 27 days // NOT end - 28: the window is inclusive
+ *
+ * Today is excluded because Analytics data for the current day is incomplete.
+ * Computed in UTC, never local time — a machine in UTC+13 must not report a
+ * different default window from one in UTC-8.
+ */
+export const defaultDateRange = (now: Date): DateRange => {
+ const end = addUtcDays(now, -1)
+ return { start: formatDateOnly(addUtcDays(end, -27)), end: formatDateOnly(end) }
+}
+
+/**
+ * The range `status --check` probes with. Note it INCLUDES today, unlike the
+ * command defaults: it is a liveness probe, not a report.
+ */
+export const statusCheckDateRange = (now: Date): DateRange => ({
+ start: formatDateOnly(addUtcDays(now, -7)),
+ end: formatDateOnly(now)
+})
+
+// ---------------------------------------------------------------------------
+// Service
+// ---------------------------------------------------------------------------
+
+const decodeResponse = Schema.decodeUnknownEffect(AnalyticsResponse)
+
+/**
+ * Go decodes into `[]ColumnHeader` / `[][]any`, and `encoding/json` unmarshals
+ * a JSON `null` into a slice as a **nil slice with no error** — so
+ * `{"rows":null}` is a valid empty report there. `Schema.optional(Schema.Array)`
+ * rejects an explicit `null`, so drop those keys before decoding to keep the
+ * two implementations in agreement. Verified against Go 1.26.5.
+ */
+export const tolerateNullSlices = (body: JsonValue): JsonValue => {
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return body
+ const record = body as { readonly [key: string]: JsonValue }
+ if (record["columnHeaders"] !== null && record["rows"] !== null) return body
+ const out: Record = {}
+ for (const [key, value] of Object.entries(record)) {
+ if (value === null && (key === "columnHeaders" || key === "rows")) continue
+ out[key] = value
+ }
+ return out
+}
+
+/** Go: `fmt.Errorf("decode YouTube API response: %w", err)` — exit 6. */
+const decodeAnalyticsResponse = (
+ body: JsonValue
+): Effect.Effect =>
+ decodeResponse(tolerateNullSlices(body)).pipe(
+ Effect.catchTag("SchemaError", (cause) =>
+ Effect.fail(
+ new OperationalError({ message: `decode YouTube API response: ${cause.message}`, cause })
+ )
+ )
+ )
+
+export const makeAnalyticsApiWith = (
+ baseUrl: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const http = yield* HttpCore
+ return {
+ report: (query: AnalyticsQuery): Effect.Effect =>
+ Effect.gen(function* () {
+ const params = yield* Effect.fromResult(analyticsQueryParams(query))
+ const body = yield* http.getJson({
+ baseUrl,
+ resource: RESOURCE,
+ params,
+ authenticate: true
+ })
+ return yield* decodeAnalyticsResponse(body)
+ }),
+ normalize: normalizeAnalytics
+ } satisfies AnalyticsApiShape
+ })
+
+export const makeAnalyticsApi = makeAnalyticsApiWith(ANALYTICS_BASE_URL)
+
+export const AnalyticsApiLive = Layer.effect(AnalyticsApi, makeAnalyticsApi)
diff --git a/src/impl/archive.test.ts b/src/impl/archive.test.ts
new file mode 100644
index 0000000..078658f
--- /dev/null
+++ b/src/impl/archive.test.ts
@@ -0,0 +1,369 @@
+import { describe, expect, test } from "bun:test"
+import { gzipSync } from "node:zlib"
+import { Effect, Exit } from "effect"
+import { BunServices } from "@effect/platform-bun"
+// The stock JSON encoder is confined to src/json/encode.ts (CI greps for it),
+// so test names quote their inputs with the project's own Go-faithful encoder.
+import { encodeGoString } from "../json/encode.ts"
+import {
+ extractBinary,
+ extractFromTarGzBytes,
+ extractFromZipBytes,
+ goPathClean,
+ safeEntryMatch
+} from "./archive.ts"
+
+// ---------------------------------------------------------------------------
+// Fixtures — the TS equivalents of the Go tests' tarGzWithEntry / zipWithEntry
+// ---------------------------------------------------------------------------
+
+const encoder = new TextEncoder()
+
+const octal = (value: number, width: number): string =>
+ value.toString(8).padStart(width - 1, "0") + "\0"
+
+/** One-entry tar, ustar format, matching what `archive/tar` emits. */
+const tarWithEntry = (
+ name: string,
+ content: Uint8Array,
+ typeflag = "0"
+): Uint8Array => {
+ const header = new Uint8Array(512)
+ const put = (offset: number, text: string) => header.set(encoder.encode(text), offset)
+
+ put(0, name.slice(0, 100))
+ put(100, octal(0o755, 8))
+ put(108, octal(0, 8))
+ put(116, octal(0, 8))
+ put(124, octal(content.length, 12))
+ put(136, octal(0, 12))
+ header[156] = typeflag.charCodeAt(0)
+ put(257, "ustar\0")
+ put(263, "00")
+
+ // Checksum: the field is treated as spaces while summing.
+ header.fill(0x20, 148, 156)
+ let sum = 0
+ for (const byte of header) sum += byte
+ put(148, `${sum.toString(8).padStart(6, "0")}\0 `)
+
+ const padding = (512 - (content.length % 512)) % 512
+ const out = new Uint8Array(512 + content.length + padding + 1024)
+ out.set(header, 0)
+ out.set(content, 512)
+ return out
+}
+
+const tarGzWithEntry = (
+ name: string,
+ content: Uint8Array,
+ typeflag = "0"
+): Uint8Array => new Uint8Array(gzipSync(tarWithEntry(name, content, typeflag)))
+
+const crcTable = (() => {
+ const table = new Uint32Array(256)
+ for (let i = 0; i < 256; i++) {
+ let c = i
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
+ table[i] = c >>> 0
+ }
+ return table
+})()
+
+const crc32 = (bytes: Uint8Array): number => {
+ let c = 0xffffffff
+ for (const byte of bytes) c = crcTable[(c ^ byte) & 0xff]! ^ (c >>> 8)
+ return (c ^ 0xffffffff) >>> 0
+}
+
+/** One-entry STORED zip with a proper central directory and EOCD. */
+const zipWithEntry = (name: string, content: Uint8Array): Uint8Array => {
+ const nameBytes = encoder.encode(name)
+ const crc = crc32(content)
+ const size = content.length
+
+ const local = new Uint8Array(30 + nameBytes.length + size)
+ const lv = new DataView(local.buffer)
+ lv.setUint32(0, 0x04034b50, true)
+ lv.setUint16(4, 20, true)
+ lv.setUint16(8, 0, true) // stored
+ lv.setUint32(14, crc, true)
+ lv.setUint32(18, size, true)
+ lv.setUint32(22, size, true)
+ lv.setUint16(26, nameBytes.length, true)
+ local.set(nameBytes, 30)
+ local.set(content, 30 + nameBytes.length)
+
+ const central = new Uint8Array(46 + nameBytes.length)
+ const cv = new DataView(central.buffer)
+ cv.setUint32(0, 0x02014b50, true)
+ cv.setUint16(4, 20, true)
+ cv.setUint16(6, 20, true)
+ cv.setUint16(10, 0, true)
+ cv.setUint32(16, crc, true)
+ cv.setUint32(20, size, true)
+ cv.setUint32(24, size, true)
+ cv.setUint16(28, nameBytes.length, true)
+ cv.setUint32(42, 0, true)
+ central.set(nameBytes, 46)
+
+ const eocd = new Uint8Array(22)
+ const ev = new DataView(eocd.buffer)
+ ev.setUint32(0, 0x06054b50, true)
+ ev.setUint16(8, 1, true)
+ ev.setUint16(10, 1, true)
+ ev.setUint32(12, central.length, true)
+ ev.setUint32(16, local.length, true)
+
+ const out = new Uint8Array(local.length + central.length + eocd.length)
+ out.set(local, 0)
+ out.set(central, local.length)
+ out.set(eocd, local.length + central.length)
+ return out
+}
+
+const bytes = (text: string): Uint8Array => encoder.encode(text)
+const text = (data: Uint8Array): string => new TextDecoder().decode(data)
+
+const runExit = (effect: Effect.Effect) => Effect.runPromiseExit(effect)
+
+const runFs = (effect: Effect.Effect) =>
+ Effect.runPromiseExit(effect.pipe(Effect.provide(BunServices.layer)) as Effect.Effect)
+
+const failureMessage = (exit: Exit.Exit): string => {
+ if (Exit.isSuccess(exit)) throw new Error("expected a failure")
+ return String(exit.cause)
+}
+
+// ---------------------------------------------------------------------------
+
+describe("goPathClean", () => {
+ /** Verified row by row against a Go program calling path.Clean. */
+ const cases: ReadonlyArray = [
+ ["", "."],
+ [".", "."],
+ ["/", "/"],
+ ["oytc", "oytc"],
+ ["./oytc", "oytc"],
+ ["oytc/", "oytc"],
+ ["/oytc", "/oytc"],
+ ["//oytc", "/oytc"],
+ ["../oytc", "../oytc"],
+ ["nested/oytc", "nested/oytc"],
+ ["a/../oytc", "oytc"],
+ ["./././oytc", "oytc"],
+ ["oytc/.", "oytc"],
+ ["/../oytc", "/oytc"],
+ ["a//b/../../oytc", "oytc"],
+ ["oytc//", "oytc"],
+ ["/oytc/", "/oytc"],
+ ["...", "..."],
+ ["..", ".."],
+ ["a/..", "."],
+ ["oytc.exe", "oytc.exe"]
+ ]
+
+ for (const [input, want] of cases) {
+ test(`Clean(${encodeGoString(input)}) = ${encodeGoString(want)}`, () => {
+ expect(goPathClean(input)).toBe(want)
+ })
+ }
+
+ test("differs from node's posix normalize on a trailing slash, which is why it exists", () => {
+ // node: "oytc/" -> "oytc/"; Go: "oytc/" -> "oytc". Using node's would let
+ // an entry named "oytc/" slip past the exact-match check.
+ expect(goPathClean("oytc/")).toBe("oytc")
+ })
+})
+
+describe("safeEntryMatch — the path-traversal defense", () => {
+ test("accepts the exact expected name", () => {
+ expect(safeEntryMatch("oytc", "oytc")).toBe(true)
+ expect(safeEntryMatch("./oytc", "oytc")).toBe(true)
+ expect(safeEntryMatch("oytc.exe", "oytc.exe")).toBe(true)
+ })
+
+ test("rejects traversal, absolute and nested paths", () => {
+ for (const entry of ["../oytc", "/oytc", "nested/oytc", "..\\oytc", "a/b/oytc", "/../oytc"]) {
+ expect(safeEntryMatch(entry, "oytc")).toBe(false)
+ }
+ })
+
+ test("normalizes backslashes before cleaning, so ..\\oytc cannot sneak through", () => {
+ // Without the replaceAll, path.Clean would leave "..\oytc" intact and it
+ // would compare unequal but reach the filesystem as a literal filename.
+ expect(safeEntryMatch("..\\oytc", "oytc")).toBe(false)
+ expect(safeEntryMatch(".\\oytc", "oytc")).toBe(true)
+ })
+
+ test("rejects a name that merely contains the expected one", () => {
+ expect(safeEntryMatch("oytc2", "oytc")).toBe(false)
+ expect(safeEntryMatch("myoytc", "oytc")).toBe(false)
+ expect(safeEntryMatch("oytc.exe", "oytc")).toBe(false)
+ })
+})
+
+/** Port of Go's `TestExtractRejectsPathTraversal`, same four entries. */
+describe("TestExtractRejectsPathTraversal", () => {
+ for (const entry of ["../oytc", "/oytc", "nested/oytc", "..\\oytc"]) {
+ test(`tar entry ${encodeGoString(entry)} is refused`, async () => {
+ const archive = tarGzWithEntry(entry, bytes("evil"))
+ const exit = await runExit(extractFromTarGzBytes(archive, "oytc"))
+ expect(failureMessage(exit)).toContain("does not contain")
+ })
+
+ test(`zip entry ${encodeGoString(entry)} is refused`, async () => {
+ const archive = zipWithEntry(entry, bytes("evil"))
+ const exit = await runExit(extractFromZipBytes(archive, "oytc"))
+ expect(failureMessage(exit)).toContain("does not contain")
+ })
+ }
+})
+
+describe("extractFromTarGzBytes", () => {
+ test("extracts the binary at the archive root", async () => {
+ const exit = await runExit(extractFromTarGzBytes(tarGzWithEntry("oytc", bytes("payload")), "oytc"))
+ expect(Exit.isSuccess(exit)).toBe(true)
+ if (Exit.isSuccess(exit)) expect(text(exit.value)).toBe("payload")
+ })
+
+ test("preserves binary content byte for byte", async () => {
+ const content = new Uint8Array(4096)
+ for (let i = 0; i < content.length; i++) content[i] = i % 256
+ const exit = await runExit(extractFromTarGzBytes(tarGzWithEntry("oytc", content), "oytc"))
+ if (!Exit.isSuccess(exit)) throw new Error("expected success")
+ expect(exit.value).toEqual(content)
+ })
+
+ test("an empty entry extracts as empty rather than failing", async () => {
+ const exit = await runExit(
+ extractFromTarGzBytes(tarGzWithEntry("oytc", new Uint8Array(0)), "oytc")
+ )
+ if (!Exit.isSuccess(exit)) throw new Error("expected success")
+ expect(exit.value.length).toBe(0)
+ })
+
+ test("skips non-regular entries — a symlink named oytc is not the binary", async () => {
+ // typeflag "2" is a symlink. Go's `header.Typeflag != tar.TypeReg` skip is
+ // what stops an archive from redirecting the write through a link.
+ const exit = await runExit(
+ extractFromTarGzBytes(tarGzWithEntry("oytc", bytes("evil"), "2"), "oytc")
+ )
+ expect(failureMessage(exit)).toContain('does not contain "oytc"')
+ })
+
+ test("skips directory entries", async () => {
+ const exit = await runExit(
+ extractFromTarGzBytes(tarGzWithEntry("oytc", new Uint8Array(0), "5"), "oytc")
+ )
+ expect(failureMessage(exit)).toContain("does not contain")
+ })
+
+ test("accepts TypeRegA (NUL typeflag), which Go's reader normalizes to a regular file", async () => {
+ const exit = await runExit(
+ extractFromTarGzBytes(tarGzWithEntry("oytc", bytes("old-format"), "\0"), "oytc")
+ )
+ if (!Exit.isSuccess(exit)) throw new Error("expected success")
+ expect(text(exit.value)).toBe("old-format")
+ })
+
+ test("reports a missing entry, not a crash, for an archive of something else", async () => {
+ const exit = await runExit(extractFromTarGzBytes(tarGzWithEntry("README", bytes("x")), "oytc"))
+ expect(failureMessage(exit)).toContain('release archive does not contain "oytc"')
+ })
+
+ test("non-gzip input fails as an unreadable archive", async () => {
+ const exit = await runExit(extractFromTarGzBytes(bytes("this is not gzip"), "oytc"))
+ expect(failureMessage(exit)).toContain("open release archive")
+ })
+
+ test("truncated gzip fails rather than returning partial bytes", async () => {
+ const full = tarGzWithEntry("oytc", bytes("payload"))
+ const exit = await runExit(extractFromTarGzBytes(full.subarray(0, full.length - 10), "oytc"))
+ expect(Exit.isSuccess(exit)).toBe(false)
+ })
+
+ test("looks for oytc.exe when that is what was asked for", async () => {
+ const archive = tarGzWithEntry("oytc.exe", bytes("win"))
+ const found = await runExit(extractFromTarGzBytes(archive, "oytc.exe"))
+ if (!Exit.isSuccess(found)) throw new Error("expected success")
+ expect(text(found.value)).toBe("win")
+
+ const missing = await runExit(extractFromTarGzBytes(archive, "oytc"))
+ expect(failureMessage(missing)).toContain("does not contain")
+ })
+})
+
+describe("extractFromZipBytes", () => {
+ test("extracts a stored entry", async () => {
+ const exit = await runExit(extractFromZipBytes(zipWithEntry("oytc.exe", bytes("winbin")), "oytc.exe"))
+ if (!Exit.isSuccess(exit)) throw new Error("expected success")
+ expect(text(exit.value)).toBe("winbin")
+ })
+
+ test("skips directory entries (trailing slash)", async () => {
+ const exit = await runExit(extractFromZipBytes(zipWithEntry("oytc/", new Uint8Array(0)), "oytc"))
+ expect(failureMessage(exit)).toContain("does not contain")
+ })
+
+ test("reports a missing entry", async () => {
+ const exit = await runExit(extractFromZipBytes(zipWithEntry("other.exe", bytes("x")), "oytc.exe"))
+ expect(failureMessage(exit)).toContain('release archive does not contain "oytc.exe"')
+ })
+
+ test("garbage input fails as an invalid zip", async () => {
+ const exit = await runExit(extractFromZipBytes(bytes("not a zip at all"), "oytc.exe"))
+ expect(failureMessage(exit)).toContain("open release archive")
+ })
+})
+
+/**
+ * The Go implementation chooses zip when the path ends in `.zip` OR when
+ * goos is windows. Both halves of that disjunction are exercised.
+ */
+describe("extractBinary — format selection", () => {
+ const withTempFile = async (
+ name: string,
+ content: Uint8Array,
+ use: (path: string) => Promise
+ ): Promise => {
+ const dir = `/tmp/oytc-archive-test-${Math.floor(Math.random() * 1e9)}`
+ await Bun.$`mkdir -p ${dir}`.quiet()
+ const file = `${dir}/${name}`
+ await Bun.write(file, content)
+ try {
+ return await use(file)
+ } finally {
+ await Bun.$`rm -rf ${dir}`.quiet()
+ }
+ }
+
+ test("tar.gz on linux", async () => {
+ await withTempFile("a.tar.gz", tarGzWithEntry("oytc", bytes("linux-bin")), async (file) => {
+ const exit = await runFs(extractBinary(file, "linux", "oytc"))
+ if (!Exit.isSuccess(exit)) throw new Error(failureMessage(exit))
+ expect(text(exit.value as Uint8Array)).toBe("linux-bin")
+ })
+ })
+
+ test("zip on windows even when the path does not end in .zip", async () => {
+ await withTempFile("archive.bin", zipWithEntry("oytc.exe", bytes("win-bin")), async (file) => {
+ const exit = await runFs(extractBinary(file, "windows", "oytc.exe"))
+ if (!Exit.isSuccess(exit)) throw new Error(failureMessage(exit))
+ expect(text(exit.value as Uint8Array)).toBe("win-bin")
+ })
+ })
+
+ test("zip when the path ends in .zip even on linux", async () => {
+ await withTempFile("a.zip", zipWithEntry("oytc", bytes("zipped")), async (file) => {
+ const exit = await runFs(extractBinary(file, "linux", "oytc"))
+ if (!Exit.isSuccess(exit)) throw new Error(failureMessage(exit))
+ expect(text(exit.value as Uint8Array)).toBe("zipped")
+ })
+ })
+
+ test("a missing archive file fails as unreadable", async () => {
+ const exit = await runFs(extractBinary("/tmp/definitely-not-here.tar.gz", "linux", "oytc"))
+ expect(failureMessage(exit)).toContain("open release archive")
+ })
+})
diff --git a/src/impl/archive.ts b/src/impl/archive.ts
index ca14f9a..24f8bdf 100644
--- a/src/impl/archive.ts
+++ b/src/impl/archive.ts
@@ -1,2 +1,351 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Release-archive extraction — `tar.gz` and `zip`, with the path-traversal
+ * defense from `internal/update/update.go`.
+ *
+ * The defense is deliberately not a prefix check or a `..` scan. An entry
+ * qualifies only when
+ *
+ * path.Clean(name.replaceAll("\\", "/")) ===
+ *
+ * i.e. an EXACT match against `oytc` / `oytc.exe`. That single rule rejects
+ * `../oytc`, `/oytc`, `nested/oytc` and `..\oytc` at once, and it also means a
+ * malicious archive cannot smuggle a second file past us: nothing but the one
+ * expected name is ever read, and nothing is ever written to an attacker-named
+ * path — the payload goes to a temp file we name ourselves.
+ *
+ * Neither format is streamed. Both are bounded by the 256 MiB download cap
+ * upstream, so the whole archive is already on disk and fits in memory; a
+ * streaming tar reader would buy nothing and cost the ability to read a zip's
+ * central directory (which lives at the END of the file).
+ */
+
+import { gunzipSync, inflateRawSync } from "node:zlib"
+import { Effect, FileSystem } from "effect"
+import { OperationalError } from "../domain/errors.ts"
+
+/** `256 << 20`. Mirrors Go's `maxArchiveBytes`. */
+export const MAX_ARCHIVE_BYTES = 256 << 20
+
+/**
+ * Go's `path.Clean`. Node's `path.posix.normalize` is close but not equal —
+ * it preserves a trailing slash (`"oytc/"` stays `"oytc/"` where Go yields
+ * `"oytc"`), so it cannot be substituted here.
+ */
+export const goPathClean = (value: string): string => {
+ if (value === "") return "."
+ const rooted = value.startsWith("/")
+ const segments = value.split("/")
+ const out: Array = []
+ for (const segment of segments) {
+ if (segment === "" || segment === ".") continue
+ if (segment === "..") {
+ if (out.length > 0 && out[out.length - 1] !== "..") {
+ out.pop()
+ continue
+ }
+ // A rooted path cannot escape its root: `/../x` cleans to `/x`.
+ if (rooted) continue
+ out.push("..")
+ continue
+ }
+ out.push(segment)
+ }
+ const joined = out.join("/")
+ if (rooted) return `/${joined}`
+ return joined === "" ? "." : joined
+}
+
+/**
+ * The traversal defense. `want` is a bare filename (`oytc` / `oytc.exe`);
+ * anything that does not clean to exactly that is not our binary.
+ */
+export const safeEntryMatch = (name: string, want: string): boolean =>
+ goPathClean(name.replaceAll("\\", "/")) === want
+
+const fail = (message: string, cause?: unknown) =>
+ Effect.fail(new OperationalError({ message, ...(cause === undefined ? {} : { cause }) }))
+
+const notContained = (want: string) => `release archive does not contain "${want}"`
+
+// ---------------------------------------------------------------------------
+// tar
+// ---------------------------------------------------------------------------
+
+const TAR_BLOCK = 512
+
+const decodeAscii = (bytes: Uint8Array): string => {
+ let out = ""
+ for (const byte of bytes) out += String.fromCharCode(byte)
+ return out
+}
+
+/** A NUL-terminated (or space-padded) tar header string field. */
+const headerString = (block: Uint8Array, offset: number, length: number): string => {
+ const slice = block.subarray(offset, offset + length)
+ let end = slice.length
+ for (let i = 0; i < slice.length; i++) {
+ if (slice[i] === 0) {
+ end = i
+ break
+ }
+ }
+ return new TextDecoder().decode(slice.subarray(0, end))
+}
+
+/**
+ * A tar numeric field: octal ASCII, or GNU base-256 when the high bit of the
+ * first byte is set. Returns `undefined` for a field that is neither.
+ */
+const headerNumber = (block: Uint8Array, offset: number, length: number): number | undefined => {
+ const slice = block.subarray(offset, offset + length)
+ const first = slice[0] ?? 0
+ if ((first & 0x80) !== 0) {
+ let value = 0n
+ for (let i = 0; i < slice.length; i++) {
+ const byte = slice[i]!
+ value = (value << 8n) | BigInt(i === 0 ? byte & 0x7f : byte)
+ }
+ const asNumber = Number(value)
+ return Number.isSafeInteger(asNumber) ? asNumber : undefined
+ }
+ const text = decodeAscii(slice).replace(/[\0 ]+$/, "").trim()
+ if (text === "") return 0
+ if (!/^[0-7]+$/.test(text)) return undefined
+ return Number.parseInt(text, 8)
+}
+
+const isZeroBlock = (block: Uint8Array): boolean => block.every((byte) => byte === 0)
+
+interface TarEntry {
+ readonly name: string
+ readonly typeflag: string
+ readonly data: Uint8Array
+}
+
+/**
+ * Walk a tar archive, yielding regular-file entries. Handles the ustar `prefix`
+ * field, GNU `L` long names, and PAX `x` `path=` records — the same set Go's
+ * `archive/tar` transparently resolves, so an archive Go accepted still works.
+ *
+ * `\0` (TypeRegA) is normalized to `0` exactly as Go's reader does; verified
+ * against Go by hand-building a TypeRegA header.
+ */
+function* tarEntries(bytes: Uint8Array): Generator {
+ let offset = 0
+ let pendingLongName: string | undefined
+ let pendingPaxPath: string | undefined
+
+ while (offset + TAR_BLOCK <= bytes.length) {
+ const header = bytes.subarray(offset, offset + TAR_BLOCK)
+ if (isZeroBlock(header)) return
+ offset += TAR_BLOCK
+
+ const size = headerNumber(header, 124, 12)
+ if (size === undefined || size < 0) return
+
+ const dataEnd = offset + size
+ if (dataEnd > bytes.length) return
+ const data = bytes.subarray(offset, dataEnd)
+ offset = dataEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
+
+ const rawTypeflag = String.fromCharCode(header[156] ?? 0)
+ let name = headerString(header, 0, 100)
+ const magic = decodeAscii(header.subarray(257, 263))
+ if (magic.startsWith("ustar")) {
+ const prefix = headerString(header, 345, 155)
+ if (prefix !== "") name = `${prefix}/${name}`
+ }
+
+ if (rawTypeflag === "L") {
+ pendingLongName = new TextDecoder().decode(data).replace(/\0+$/, "")
+ continue
+ }
+ if (rawTypeflag === "K") continue
+ if (rawTypeflag === "x" || rawTypeflag === "X") {
+ pendingPaxPath = paxPath(data)
+ continue
+ }
+ if (rawTypeflag === "g") continue
+
+ const effectiveName = pendingPaxPath ?? pendingLongName ?? name
+ pendingLongName = undefined
+ pendingPaxPath = undefined
+
+ // Go normalizes TypeRegA ("\0"): a trailing slash means a directory,
+ // otherwise a regular file.
+ const typeflag =
+ rawTypeflag === "\0" || rawTypeflag === "" ? (effectiveName.endsWith("/") ? "5" : "0") : rawTypeflag
+
+ yield { name: effectiveName, typeflag, data }
+ }
+}
+
+/** `path=` out of a PAX extended-header record set. */
+const paxPath = (data: Uint8Array): string | undefined => {
+ const text = new TextDecoder().decode(data)
+ let index = 0
+ while (index < text.length) {
+ const space = text.indexOf(" ", index)
+ if (space < 0) return undefined
+ const length = Number.parseInt(text.slice(index, space), 10)
+ if (!Number.isFinite(length) || length <= 0) return undefined
+ const record = text.slice(index, index + length)
+ const equals = record.indexOf("=")
+ if (equals > 0) {
+ const key = record.slice(record.indexOf(" ") + 1, equals)
+ if (key === "path") return record.slice(equals + 1).replace(/\n$/, "")
+ }
+ index += length
+ }
+ return undefined
+}
+
+/**
+ * Extract `want` from gzipped tar bytes. Only `TypeReg` entries whose cleaned
+ * name matches exactly are considered.
+ */
+export const extractFromTarGzBytes = (
+ bytes: Uint8Array,
+ want: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const tar = yield* Effect.try({
+ try: () => new Uint8Array(gunzipSync(bytes, { maxOutputLength: MAX_ARCHIVE_BYTES })),
+ catch: (cause) => new OperationalError({ message: "open release archive", cause })
+ })
+ for (const entry of tarEntries(tar)) {
+ if (entry.typeflag !== "0") continue
+ if (!safeEntryMatch(entry.name, want)) continue
+ return entry.data.slice(0, MAX_ARCHIVE_BYTES)
+ }
+ return yield* fail(notContained(want))
+ })
+
+// ---------------------------------------------------------------------------
+// zip
+// ---------------------------------------------------------------------------
+
+const EOCD_SIGNATURE = 0x06054b50
+const CENTRAL_SIGNATURE = 0x02014b50
+const LOCAL_SIGNATURE = 0x04034b50
+
+interface ZipEntry {
+ readonly name: string
+ readonly method: number
+ readonly compressedSize: number
+ readonly localHeaderOffset: number
+}
+
+const findEocd = (view: DataView): number | undefined => {
+ const minimum = 22
+ if (view.byteLength < minimum) return undefined
+ // The comment may be up to 65535 bytes; scan back from the end.
+ const limit = Math.max(0, view.byteLength - minimum - 0xffff)
+ for (let offset = view.byteLength - minimum; offset >= limit; offset--) {
+ if (view.getUint32(offset, true) === EOCD_SIGNATURE) return offset
+ }
+ return undefined
+}
+
+/**
+ * Read the central directory. Sizes come from there rather than from the local
+ * header, because an archive written with a streaming writer sets general
+ * purpose bit 3 and leaves the local header's sizes as zero.
+ */
+const zipEntries = (bytes: Uint8Array): ReadonlyArray | undefined => {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ const eocd = findEocd(view)
+ if (eocd === undefined) return undefined
+
+ const count = view.getUint16(eocd + 10, true)
+ let offset = view.getUint32(eocd + 16, true)
+ const decoder = new TextDecoder()
+ const entries: Array = []
+
+ for (let i = 0; i < count; i++) {
+ if (offset + 46 > bytes.length) return undefined
+ if (view.getUint32(offset, true) !== CENTRAL_SIGNATURE) return undefined
+ const method = view.getUint16(offset + 10, true)
+ const compressedSize = view.getUint32(offset + 20, true)
+ const nameLength = view.getUint16(offset + 28, true)
+ const extraLength = view.getUint16(offset + 30, true)
+ const commentLength = view.getUint16(offset + 32, true)
+ const localHeaderOffset = view.getUint32(offset + 42, true)
+ const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength))
+ entries.push({ name, method, compressedSize, localHeaderOffset })
+ offset += 46 + nameLength + extraLength + commentLength
+ }
+ return entries
+}
+
+const zipEntryData = (
+ bytes: Uint8Array,
+ entry: ZipEntry
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ const start = entry.localHeaderOffset
+ if (start + 30 > bytes.length || view.getUint32(start, true) !== LOCAL_SIGNATURE) {
+ return yield* fail("open release archive: corrupt zip local header")
+ }
+ const nameLength = view.getUint16(start + 26, true)
+ const extraLength = view.getUint16(start + 28, true)
+ const dataStart = start + 30 + nameLength + extraLength
+ const dataEnd = dataStart + entry.compressedSize
+ if (dataEnd > bytes.length) {
+ return yield* fail("read release archive: truncated zip entry")
+ }
+ const raw = bytes.subarray(dataStart, dataEnd)
+
+ if (entry.method === 0) return raw.slice(0, MAX_ARCHIVE_BYTES)
+ if (entry.method === 8) {
+ return yield* Effect.try({
+ try: () => new Uint8Array(inflateRawSync(raw, { maxOutputLength: MAX_ARCHIVE_BYTES })),
+ catch: (cause) => new OperationalError({ message: "read release archive", cause })
+ })
+ }
+ return yield* fail(`read release archive: unsupported zip compression method ${entry.method}`)
+ })
+
+/** Extract `want` from zip bytes. Directory entries (trailing `/`) are skipped. */
+export const extractFromZipBytes = (
+ bytes: Uint8Array,
+ want: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const entries = zipEntries(bytes)
+ if (entries === undefined) {
+ return yield* fail("open release archive: not a valid zip file")
+ }
+ for (const entry of entries) {
+ if (entry.name.endsWith("/")) continue
+ if (!safeEntryMatch(entry.name, want)) continue
+ return yield* zipEntryData(bytes, entry)
+ }
+ return yield* fail(notContained(want))
+ })
+
+/**
+ * Pull the oytc binary out of a verified archive on disk.
+ *
+ * Format selection matches Go: zip when the path ends in `.zip` OR when
+ * `goos === "windows"`, tar.gz otherwise.
+ */
+export const extractBinary = (
+ archivePath: string,
+ goos: string,
+ want: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const bytes = yield* fs
+ .readFile(archivePath)
+ .pipe(
+ Effect.catch((cause) =>
+ Effect.fail(new OperationalError({ message: "open release archive", cause }))
+ )
+ )
+ return yield* archivePath.endsWith(".zip") || goos === "windows"
+ ? extractFromZipBytes(bytes, want)
+ : extractFromTarGzBytes(bytes, want)
+ })
diff --git a/src/impl/atomicWrite.test.ts b/src/impl/atomicWrite.test.ts
new file mode 100644
index 0000000..825d574
--- /dev/null
+++ b/src/impl/atomicWrite.test.ts
@@ -0,0 +1,150 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Cause, Effect, Exit, FileSystem, Layer, Path } from "effect"
+import { BunServices } from "@effect/platform-bun"
+import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
+import { chmodSync, mkdirSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { atomicWriteSecure, ensureSecureDirectory } from "./atomicWrite.ts"
+
+const temporaries: Array = []
+
+const tempDir = (): string => {
+ const dir = mkdtempSync(join(tmpdir(), "oytc-atomic-"))
+ temporaries.push(dir)
+ return dir
+}
+
+afterEach(() => {
+ while (temporaries.length > 0) {
+ const dir = temporaries.pop()!
+ rmSync(dir, { recursive: true, force: true })
+ }
+})
+
+const platform: Layer.Layer = Layer.mergeAll(
+ BunServices.layer
+) as unknown as Layer.Layer
+
+const run = (effect: Effect.Effect): Promise =>
+ Effect.runPromise(effect.pipe(Effect.provide(platform)))
+
+const runExit = (effect: Effect.Effect) =>
+ Effect.runPromise(Effect.exit(effect.pipe(Effect.provide(platform))))
+
+const withServices = (
+ f: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ return yield* f(fs, path)
+ })
+
+const mode = (target: string): number => statSync(target).mode & 0o777
+
+describe("atomicWriteSecure", () => {
+ test("creates missing directories at 0700 and the file at 0600", async () => {
+ const root = tempDir()
+ const destination = join(root, "nested", "deeper", "auth.json")
+
+ const result = await run(
+ withServices((fs, path) => atomicWriteSecure(fs, path, destination, "payload\n"))
+ )
+
+ expect(result).toBe(destination)
+ expect(readFileSync(destination, "utf8")).toBe("payload\n")
+ expect(mode(destination)).toBe(0o600)
+ expect(mode(join(root, "nested", "deeper"))).toBe(0o700)
+ })
+
+ test("re-tightens a pre-existing loose directory to 0700", async () => {
+ const root = tempDir()
+ const dir = join(root, "loose")
+ mkdirSync(dir, { mode: 0o777 })
+ chmodSync(dir, 0o777)
+ expect(mode(dir)).toBe(0o777)
+
+ await run(
+ withServices((fs, path) => atomicWriteSecure(fs, path, join(dir, "auth.json"), "x\n"))
+ )
+ expect(mode(dir)).toBe(0o700)
+ })
+
+ test("replaces an existing file and re-tightens its mode", async () => {
+ const root = tempDir()
+ const destination = join(root, "auth.json")
+ writeFileSync(destination, "stale", { mode: 0o644 })
+ chmodSync(destination, 0o644)
+
+ await run(withServices((fs, path) => atomicWriteSecure(fs, path, destination, "fresh\n")))
+
+ expect(readFileSync(destination, "utf8")).toBe("fresh\n")
+ expect(mode(destination)).toBe(0o600)
+ })
+
+ test("leaves no .auth-*.tmp behind on success", async () => {
+ const root = tempDir()
+ await run(
+ withServices((fs, path) => atomicWriteSecure(fs, path, join(root, "auth.json"), "x\n"))
+ )
+ expect(readdirSync(root).filter((name) => name.endsWith(".tmp"))).toEqual([])
+ })
+
+ test("leaves no temp file behind when the write fails", async () => {
+ const root = tempDir()
+ // A directory where the destination should be makes the rename fail while
+ // the temp file has already been created — Go's `defer os.Remove` path.
+ const destination = join(root, "auth.json")
+ mkdirSync(destination)
+ // Put something inside so the rename cannot succeed by replacing an
+ // empty directory (which some platforms allow).
+ writeFileSync(join(destination, "occupant"), "x")
+
+ const exit = await runExit(
+ withServices((fs, path) => atomicWriteSecure(fs, path, destination, "x\n"))
+ )
+ expect(Exit.isFailure(exit)).toBe(true)
+ expect(readdirSync(root).filter((name) => name.endsWith(".tmp"))).toEqual([])
+ })
+
+ test("writes the payload verbatim, including a trailing newline and unicode", async () => {
+ const root = tempDir()
+ const destination = join(root, "auth.json")
+ const payload = '{\n "api_key": "é✓"\n}\n'
+ await run(withServices((fs, path) => atomicWriteSecure(fs, path, destination, payload)))
+ expect(readFileSync(destination, "utf8")).toBe(payload)
+ })
+
+ test("truncates rather than appending when replacing a longer file", async () => {
+ const root = tempDir()
+ const destination = join(root, "auth.json")
+ writeFileSync(destination, "x".repeat(4096))
+ await run(withServices((fs, path) => atomicWriteSecure(fs, path, destination, "tiny\n")))
+ expect(readFileSync(destination, "utf8")).toBe("tiny\n")
+ })
+})
+
+describe("ensureSecureDirectory", () => {
+ test("is idempotent and always ends at 0700", async () => {
+ const root = tempDir()
+ const dir = join(root, "a", "b")
+ await run(withServices((fs) => ensureSecureDirectory(fs, dir)))
+ expect(mode(dir)).toBe(0o700)
+ chmodSync(dir, 0o755)
+ await run(withServices((fs) => ensureSecureDirectory(fs, dir)))
+ expect(mode(dir)).toBe(0o700)
+ })
+
+ test("fails with Go's message when the path is occupied by a file", async () => {
+ const root = tempDir()
+ const blocked = join(root, "blocker")
+ writeFileSync(blocked, "x")
+
+ const exit = await runExit(withServices((fs) => ensureSecureDirectory(fs, blocked)))
+ expect(Exit.isFailure(exit)).toBe(true)
+ if (Exit.isFailure(exit)) {
+ expect(Cause.pretty(exit.cause)).toContain("create config directory")
+ }
+ })
+})
diff --git a/src/impl/atomicWrite.ts b/src/impl/atomicWrite.ts
index ca14f9a..f258260 100644
--- a/src/impl/atomicWrite.ts
+++ b/src/impl/atomicWrite.ts
@@ -1,2 +1,117 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Atomic, permission-hardened file replacement — Go's `config.saveFile`.
+ *
+ * The sequence matters and is reproduced step for step:
+ *
+ * 1. `mkdir -p` the parent with mode 0700.
+ * 2. `chmod 0700` the parent — **best effort**. This re-tightens a directory
+ * that already existed with loose permissions; `mkdir` would not.
+ * 3. Create a temp file `.auth-.tmp` in the SAME directory, so the
+ * rename in step 6 stays on one filesystem and is therefore atomic.
+ * 4. `chmod 0600` the temp file, then write, then **fsync**. The fsync is
+ * what makes the rename meaningful: without it a crash can leave the
+ * renamed file present but empty.
+ * 5. Rename over the destination.
+ * 6. `chmod 0600` the destination — best effort.
+ *
+ * Every failure path removes the temp file, matching Go's
+ * `defer os.Remove(tmpName)` (a harmless no-op after a successful rename).
+ *
+ * **Best effort means best effort.** On Windows `chmod` only toggles the
+ * read-only attribute, and Go ignores both hardening chmod errors outright. A
+ * `chmod` failure here must never fail the write.
+ */
+
+import { Effect, FileSystem, Path } from "effect"
+import { OperationalError } from "../domain/errors.ts"
+
+/** Go's `os.CreateTemp(dir, ".auth-*.tmp")` naming: a random decimal infix. */
+const tempName = (): string => `.auth-${Math.floor(Math.random() * 0xffffffff)}.tmp`
+
+const wrap = (message: string) => (cause: unknown) => new OperationalError({ message, cause })
+
+/** Ignore every failure, including defects — used for the hardening chmods. */
+const bestEffort = (effect: Effect.Effect): Effect.Effect =>
+ effect.pipe(
+ Effect.asVoid,
+ Effect.catchCause(() => Effect.void)
+ )
+
+/**
+ * `mkdir -p dir` at 0700, then re-tighten an existing directory to 0700.
+ * Exposed separately because `Remove()` needs the directory to exist for the
+ * lockfile without writing anything.
+ *
+ * `fs`/`path` are passed rather than pulled from context so callers that
+ * already resolved them do not re-introduce a `FileSystem` requirement into
+ * the closures they hand back from a service constructor.
+ */
+export const ensureSecureDirectory = (
+ fs: FileSystem.FileSystem,
+ directory: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ yield* fs
+ .makeDirectory(directory, { recursive: true, mode: 0o700 })
+ .pipe(Effect.catch((cause) => Effect.fail(wrap("create config directory")(cause))))
+ yield* bestEffort(fs.chmod(directory, 0o700))
+ })
+
+/**
+ * Write `contents` to `destination` atomically, with 0600 on the file and 0700
+ * on its directory. Returns `destination`.
+ */
+export const atomicWriteSecure = (
+ fs: FileSystem.FileSystem,
+ path: Path.Path,
+ destination: string,
+ contents: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const directory = path.dirname(destination)
+
+ yield* ensureSecureDirectory(fs, directory)
+
+ const temporary = path.join(directory, tempName())
+
+ // `wx` fails if the random name collided, which is the correct outcome:
+ // creating the temp file must never clobber an existing file.
+ yield* fs
+ .writeFileString(temporary, "", { flag: "wx", mode: 0o600 })
+ .pipe(Effect.catch((cause) => Effect.fail(wrap("create temporary credential file")(cause))))
+
+ const install = Effect.gen(function* () {
+ // Go's belt-and-braces tmp.Chmod(0600) after CreateTemp already made it
+ // 0600. Unlike the hardening chmods this one IS fatal in Go.
+ yield* fs
+ .chmod(temporary, 0o600)
+ .pipe(Effect.catch((cause) => Effect.fail(wrap("secure temporary credential file")(cause))))
+
+ yield* Effect.scoped(
+ Effect.gen(function* () {
+ const handle = yield* fs
+ .open(temporary, { flag: "w", mode: 0o600 })
+ .pipe(Effect.catch((cause) => Effect.fail(wrap("write credentials")(cause))))
+ yield* handle
+ .writeAll(new TextEncoder().encode(contents))
+ .pipe(Effect.catch((cause) => Effect.fail(wrap("write credentials")(cause))))
+ // fsync BEFORE the rename, or a crash can publish an empty file.
+ yield* handle.sync.pipe(
+ Effect.catch((cause) => Effect.fail(wrap("sync credentials")(cause)))
+ )
+ })
+ )
+
+ yield* fs
+ .rename(temporary, destination)
+ .pipe(Effect.catch((cause) => Effect.fail(wrap("install credentials")(cause))))
+
+ yield* bestEffort(fs.chmod(destination, 0o600))
+ return destination
+ })
+
+ // Go's `defer os.Remove(tmpName)`: a no-op once the rename succeeded.
+ return yield* install.pipe(
+ Effect.onExit(() => bestEffort(fs.remove(temporary, { force: true })))
+ )
+ })
diff --git a/src/impl/browserOpener.test.ts b/src/impl/browserOpener.test.ts
new file mode 100644
index 0000000..207dec3
--- /dev/null
+++ b/src/impl/browserOpener.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, test } from "bun:test"
+import { Effect, Layer, Option } from "effect"
+// `effect/testing` is a stable subpath, so the src/effect.ts barrel rule does not
+// apply to it (that rule covers the unstable subpath only).
+import { TestConsole } from "effect/testing"
+import { ProcessEnv, type ProcessEnvShape } from "../services/index.ts"
+import { OperationalError } from "../domain/errors.ts"
+import { browserCommand, launch, makeBrowserOpener } from "./browserOpener.ts"
+
+const URL_UNDER_TEST = "https://accounts.google.com/o/oauth2/v2/auth?client_id=x&state=y"
+
+describe("browserCommand", () => {
+ test("darwin uses open", () => {
+ expect(browserCommand("darwin", URL_UNDER_TEST)).toEqual({
+ command: "open",
+ args: [URL_UNDER_TEST]
+ })
+ })
+
+ test("windows uses rundll32 with the FileProtocolHandler entry point", () => {
+ expect(browserCommand("win32", URL_UNDER_TEST)).toEqual({
+ command: "rundll32",
+ args: ["url.dll,FileProtocolHandler", URL_UNDER_TEST]
+ })
+ })
+
+ test.each([["linux"], ["freebsd"], ["openbsd"], ["anything-else"]])(
+ "%s falls back to xdg-open",
+ (platform) => {
+ expect(browserCommand(platform, URL_UNDER_TEST)).toEqual({
+ command: "xdg-open",
+ args: [URL_UNDER_TEST]
+ })
+ }
+ )
+
+ test("the URL is passed as a single argv element, never shell-interpolated", () => {
+ const hostile = "https://example.com/?a=1&b=$(whoami);rm -rf /"
+ expect(browserCommand("linux", hostile).args).toEqual([hostile])
+ })
+})
+
+const envLayer = (platform: string): Layer.Layer => {
+ const shape: ProcessEnvShape = {
+ env: () => Option.none(),
+ platform,
+ arch: "arm64",
+ argv: [],
+ executablePath: Effect.succeed("/bin/oytc"),
+ isOutputTTY: false,
+ homeDir: Effect.succeed("/home/test")
+ }
+ return Layer.succeed(ProcessEnv, shape)
+}
+
+const opener = (platform: string) =>
+ Effect.provide(makeBrowserOpener, envLayer(platform))
+
+describe("BrowserOpener", () => {
+ test("a launch failure is a warning on stderr, not an error", async () => {
+ // The launcher must be guaranteed ABSENT for this to test anything. Relying
+ // on `xdg-open` being missing only holds on macOS — the Linux CI runner has
+ // it — so PATH is emptied for the duration, which makes spawn emit ENOENT on
+ // every platform. `open` must still succeed so Login keeps waiting on the
+ // loopback callback, and the warning must carry Go's exact prefix.
+ const path = process.env["PATH"]
+ process.env["PATH"] = ""
+ try {
+ const lines = await Effect.runPromise(
+ Effect.gen(function* () {
+ const browser = yield* opener("definitely-not-a-real-platform")
+ yield* browser.open(URL_UNDER_TEST)
+ return yield* TestConsole.errorLines
+ }).pipe(Effect.provide(TestConsole.layer))
+ )
+ expect(lines).toHaveLength(1)
+ expect(String(lines[0])).toStartWith("Could not open a browser automatically: ")
+ } finally {
+ process.env["PATH"] = path
+ }
+ })
+
+ test("launch resolves once the child has spawned, without waiting for it", async () => {
+ // `sleep 5` proves the effect returns on the `spawn` event rather than on
+ // exit — Go uses Start(), not Wait(), so the login must not block here.
+ const started = Date.now()
+ await Effect.runPromise(launch({ command: "sleep", args: ["5"] }))
+ expect(Date.now() - started).toBeLessThan(2000)
+ })
+
+ test("launch fails when the launcher is not on PATH", async () => {
+ const error = await Effect.runPromise(
+ Effect.flip(launch({ command: "oytc-no-such-launcher", args: ["about:blank"] }))
+ )
+ expect(error).toBeInstanceOf(Error)
+ // Node reports a missing binary asynchronously on the `error` event; assert
+ // the code so this cannot pass on some unrelated failure.
+ expect((error as NodeJS.ErrnoException).code).toBe("ENOENT")
+ })
+
+ test("the shape's error channel is `never`, as the contract requires", () => {
+ // A compile-time assertion: assigning to Effect only
+ // typechecks because `open` cannot fail.
+ const check = Effect.gen(function* () {
+ const browser = yield* opener("linux")
+ const opened: Effect.Effect = browser.open("about:blank")
+ return opened
+ })
+ expect(check).toBeDefined()
+ })
+
+ test("OperationalError is not part of this path", () => {
+ // Guards against a future refactor promoting the warning to a failure.
+ expect(new OperationalError({ message: "x" })).toBeInstanceOf(OperationalError)
+ })
+})
diff --git a/src/impl/browserOpener.ts b/src/impl/browserOpener.ts
index ca14f9a..5b79273 100644
--- a/src/impl/browserOpener.ts
+++ b/src/impl/browserOpener.ts
@@ -1,2 +1,72 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Detached browser launch for the OAuth consent screen.
+ *
+ * Go's `oauth.OpenBrowser` calls `exec.Command(...).Start()` — it never waits
+ * for the browser to exit, and a failure to launch is non-fatal: `Login` prints
+ * `Could not open a browser automatically: ` and keeps waiting on the
+ * loopback callback, because the URL has already been printed for the user to
+ * paste. `BrowserOpenerShape.open` therefore cannot fail; the warning is emitted
+ * here, on stderr, where Go emits it.
+ *
+ * `node:child_process` rather than `Bun.spawn`: the sanctioned Bun-specific
+ * import outside `main.ts` is the loopback server only, and `spawn` is
+ * identical across both runtimes.
+ *
+ * Go's `Start()` reports a launch failure synchronously; Node's `spawn` reports
+ * it asynchronously on the `error` event, so the effect waits for whichever of
+ * `spawn`/`error` fires first before returning.
+ */
+
+import { spawn } from "node:child_process"
+import { Console, Effect, Layer } from "effect"
+import { BrowserOpener, type BrowserOpenerShape, ProcessEnv } from "../services/index.ts"
+
+export interface BrowserCommand {
+ readonly command: string
+ readonly args: ReadonlyArray
+}
+
+/** darwin `open`; windows `rundll32 url.dll,FileProtocolHandler`; otherwise `xdg-open`. */
+export const browserCommand = (platform: string, url: string): BrowserCommand => {
+ switch (platform) {
+ case "darwin":
+ return { command: "open", args: [url] }
+ case "win32":
+ return { command: "rundll32", args: ["url.dll,FileProtocolHandler", url] }
+ default:
+ return { command: "xdg-open", args: [url] }
+ }
+}
+
+/** Exported so tests can drive it with a harmless binary instead of a browser. */
+export const launch = ({ command, args }: BrowserCommand): Effect.Effect =>
+ Effect.tryPromise({
+ try: () =>
+ new Promise((resolve, reject) => {
+ const child = spawn(command, [...args], {
+ detached: true,
+ stdio: "ignore"
+ })
+ child.once("error", reject)
+ child.once("spawn", () => {
+ child.unref()
+ resolve()
+ })
+ }),
+ catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause)))
+ })
+
+export const makeBrowserOpener = Effect.gen(function* () {
+ const env = yield* ProcessEnv
+ const shape: BrowserOpenerShape = {
+ open: (url) =>
+ launch(browserCommand(env.platform, url)).pipe(
+ Effect.catch((cause) =>
+ Console.error(`Could not open a browser automatically: ${cause.message}`)
+ )
+ )
+ }
+ return shape
+})
+
+export const BrowserOpenerLive = Layer.effect(BrowserOpener, makeBrowserOpener)
diff --git a/src/impl/credentialStore.test.ts b/src/impl/credentialStore.test.ts
new file mode 100644
index 0000000..5963cc6
--- /dev/null
+++ b/src/impl/credentialStore.test.ts
@@ -0,0 +1,858 @@
+/**
+ * Ports all 8 cases from `internal/config/config_test.go`, plus the
+ * cross-process shape of `TestConcurrentUpdatesAreNotLost` that the Go suite
+ * cannot express (goroutines share one flock file description; two OS
+ * processes do not).
+ *
+ * Every test gets its own temp dir via `OYTC_CONFIG_DIR`. The real config
+ * directory is never read or written.
+ */
+
+import { afterEach, describe, expect, test } from "bun:test"
+import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"
+import { BunServices } from "@effect/platform-bun"
+import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { OperationalError } from "../domain/errors.ts"
+import {
+ CredentialStore,
+ ProcessEnv,
+ type CredentialStoreShape,
+ type ProcessEnvShape,
+ type StoredOAuth
+} from "../services/index.ts"
+import { CredentialStoreLive, fingerprint } from "./credentialStore.ts"
+import { FileLockLive } from "./fileLock.ts"
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
+const temporaries: Array = []
+
+const tempDir = (): string => {
+ const dir = mkdtempSync(join(tmpdir(), "oytc-cred-"))
+ temporaries.push(dir)
+ return dir
+}
+
+afterEach(() => {
+ while (temporaries.length > 0) {
+ rmSync(temporaries.pop()!, { recursive: true, force: true })
+ }
+})
+
+const platform = BunServices.layer as unknown as Layer.Layer<
+ FileSystem.FileSystem | Path.Path
+>
+
+interface EnvOverrides {
+ readonly [name: string]: string | undefined
+}
+
+/**
+ * A ProcessEnv whose variables and platform are fully controlled, so
+ * `Dir()` resolution can be tested for darwin/windows/linux from one host.
+ */
+const testProcessEnv = (options: {
+ readonly env?: EnvOverrides
+ readonly platform?: string
+ readonly home?: string | undefined
+}): ProcessEnvShape => {
+ const env = options.env ?? {}
+ return {
+ env: (name) => Option.fromNullishOr(env[name]),
+ platform: options.platform ?? process.platform,
+ arch: process.arch,
+ argv: [],
+ executablePath: Effect.succeed("/nonexistent/oytc"),
+ isOutputTTY: false,
+ homeDir:
+ options.home === undefined
+ ? Effect.fail(new OperationalError({ message: "could not determine home directory" }))
+ : Effect.succeed(options.home)
+ }
+}
+
+/** One fresh store (and therefore one fresh lock semaphore) per invocation. */
+const storeLayer = (options: {
+ readonly env?: EnvOverrides
+ readonly platform?: string
+ readonly home?: string | undefined
+}): Layer.Layer<(typeof CredentialStore)["Identifier"]> => {
+ const processEnv = Layer.succeed(ProcessEnv, testProcessEnv(options))
+ return Layer.fresh(
+ CredentialStoreLive.pipe(
+ Layer.provide(Layer.mergeAll(platform, processEnv, FileLockLive.pipe(Layer.provide(platform))))
+ )
+ ) as unknown as Layer.Layer<(typeof CredentialStore)["Identifier"]>
+}
+
+const runIn = (
+ layer: Layer.Layer<(typeof CredentialStore)["Identifier"]>,
+ f: (store: CredentialStoreShape) => Effect.Effect
+): Promise =>
+ Effect.runPromise(
+ Effect.flatMap(CredentialStore, f).pipe(Effect.provide(layer)) as Effect.Effect
+ )
+
+const exitIn = (
+ layer: Layer.Layer<(typeof CredentialStore)["Identifier"]>,
+ f: (store: CredentialStoreShape) => Effect.Effect
+): Promise> =>
+ Effect.runPromise(
+ Effect.exit(
+ Effect.flatMap(CredentialStore, f).pipe(Effect.provide(layer)) as Effect.Effect
+ )
+ )
+
+/** A store bound to `dir` with no OYTC_API_KEY, the common case. */
+const storeAt = (dir: string, extra: EnvOverrides = {}) =>
+ storeLayer({ env: { OYTC_CONFIG_DIR: dir, ...extra } })
+
+const perm = (target: string): number => statSync(target).mode & 0o777
+
+const oauth = (overrides: Partial = {}): StoredOAuth => ({
+ clientId: "id",
+ clientSecret: "secret",
+ accessToken: "access",
+ refreshToken: "refresh",
+ expiry: "2026-02-01T12:00:00Z",
+ scopes: ["scope"],
+ ...overrides
+})
+
+const failureMessage = (exit: Exit.Exit): string =>
+ Exit.isFailure(exit) ? Cause.pretty(exit.cause) : ""
+
+// ---------------------------------------------------------------------------
+// Dir() resolution — §1.1 / §1.2
+// ---------------------------------------------------------------------------
+
+describe("dir", () => {
+ test("OYTC_CONFIG_DIR is used verbatim, with NO oytc suffix appended", async () => {
+ const dir = await runIn(
+ storeLayer({ env: { OYTC_CONFIG_DIR: "/custom/place" }, platform: "linux" }),
+ (store) => store.dir
+ )
+ expect(dir).toBe("/custom/place")
+ })
+
+ test("OYTC_CONFIG_DIR is trimmed before the emptiness test", async () => {
+ const dir = await runIn(
+ storeLayer({ env: { OYTC_CONFIG_DIR: " /custom/place " }, platform: "linux" }),
+ (store) => store.dir
+ )
+ expect(dir).toBe("/custom/place")
+ })
+
+ test("a whitespace-only OYTC_CONFIG_DIR falls through to the OS default", async () => {
+ const dir = await runIn(
+ storeLayer({ env: { OYTC_CONFIG_DIR: " " }, platform: "linux", home: "/home/u" }),
+ (store) => store.dir
+ )
+ expect(dir).toBe("/home/u/.config/oytc")
+ })
+
+ test.each([
+ ["~", "/home/u"],
+ ["~/cfg", "/home/u/cfg"],
+ ["~\\cfg", "/home/u/cfg"],
+ // ~user is NOT supported: it starts with neither "~/" nor "~\".
+ ["~other/cfg", "~other/cfg"],
+ ["/abs/path", "/abs/path"],
+ ["relative", "relative"]
+ ])("tilde expansion of %s -> %s", async (input, expected) => {
+ const dir = await runIn(
+ storeLayer({ env: { OYTC_CONFIG_DIR: input }, platform: "linux", home: "/home/u" }),
+ (store) => store.dir
+ )
+ expect(dir).toBe(expected)
+ })
+
+ test("an unresolvable home leaves a tilde path unchanged", async () => {
+ const dir = await runIn(
+ storeLayer({ env: { OYTC_CONFIG_DIR: "~/cfg" }, platform: "linux", home: undefined }),
+ (store) => store.dir
+ )
+ expect(dir).toBe("~/cfg")
+ })
+
+ test("darwin resolves to ~/Library/Application Support/oytc", async () => {
+ const dir = await runIn(storeLayer({ platform: "darwin", home: "/Users/u" }), (s) => s.dir)
+ expect(dir).toBe("/Users/u/Library/Application Support/oytc")
+ })
+
+ test("darwin propagates an unresolvable home", async () => {
+ const exit = await exitIn(storeLayer({ platform: "darwin", home: undefined }), (s) => s.dir)
+ expect(failureMessage(exit)).toContain("determine config directory")
+ })
+
+ test("windows resolves to %APPDATA%\\oytc", async () => {
+ const dir = await runIn(
+ storeLayer({ platform: "win32", env: { APPDATA: "C:\\Users\\u\\AppData\\Roaming" } }),
+ (s) => s.dir
+ )
+ // path.join is host-flavored; assert on the components, not the separator.
+ expect(dir.replaceAll("\\", "/")).toBe("C:/Users/u/AppData/Roaming/oytc")
+ })
+
+ test("windows without APPDATA is an error", async () => {
+ const exit = await exitIn(storeLayer({ platform: "win32", env: {} }), (s) => s.dir)
+ expect(failureMessage(exit)).toContain("determine config directory: APPDATA is not set")
+ })
+
+ test("linux prefers XDG_CONFIG_HOME", async () => {
+ const dir = await runIn(
+ storeLayer({ platform: "linux", env: { XDG_CONFIG_HOME: "/xdg" }, home: "/home/u" }),
+ (s) => s.dir
+ )
+ expect(dir).toBe("/xdg/oytc")
+ })
+
+ test("XDG_CONFIG_HOME is NOT trimmed — whitespace is a real value", async () => {
+ // Deliberate asymmetry with OYTC_CONFIG_DIR, matching Go: only an
+ // exactly-empty XDG_CONFIG_HOME counts as unset.
+ const dir = await runIn(
+ storeLayer({ platform: "linux", env: { XDG_CONFIG_HOME: " " }, home: "/home/u" }),
+ (s) => s.dir
+ )
+ expect(dir).toBe(" /oytc")
+ })
+
+ test("linux falls back to ~/.config/oytc", async () => {
+ const dir = await runIn(storeLayer({ platform: "linux", home: "/home/u" }), (s) => s.dir)
+ expect(dir).toBe("/home/u/.config/oytc")
+ })
+
+ test("path is dir + auth.json", async () => {
+ const layer = storeLayer({ env: { OYTC_CONFIG_DIR: "/custom" }, platform: "linux" })
+ expect(await runIn(layer, (s) => s.path)).toBe("/custom/auth.json")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestSaveLoadRemoveAndModes
+// ---------------------------------------------------------------------------
+
+describe("TestSaveLoadRemoveAndModes", () => {
+ test("save, load, replace, remove, and file modes", async () => {
+ const root = tempDir()
+ const configDir = join(root, "nested")
+ const layer = storeAt(configDir)
+
+ const path = await runIn(layer, (store) => store.save("test-secret-key"))
+ expect(path).toBe(join(configDir, "auth.json"))
+
+ expect(readFileSync(path, "utf8")).toBe('{\n "api_key": "test-secret-key"\n}\n')
+ expect(perm(path)).toBe(0o600)
+ expect(perm(configDir)).toBe(0o700)
+
+ const credentials = await runIn(layer, (store) => store.load)
+ expect(credentials.key).toBe("test-secret-key")
+ expect(credentials.source).toBe("auth.json")
+ expect(credentials.oauth).toBeUndefined()
+ expect(credentials.path).toBe(path)
+
+ await runIn(layer, (store) => store.save("replacement-secret"))
+ expect((await runIn(layer, (store) => store.load)).key).toBe("replacement-secret")
+
+ const removed = await runIn(layer, (store) => store.remove)
+ expect(removed).toEqual({ path, removed: true })
+ expect(existsSync(path)).toBe(false)
+
+ // Remove is idempotent: a missing file is (path, false, nil).
+ expect(await runIn(layer, (store) => store.remove)).toEqual({ path, removed: false })
+ })
+
+ test("save trims the key and rejects an empty one", async () => {
+ const layer = storeAt(tempDir())
+ const path = await runIn(layer, (store) => store.save(" padded-key "))
+ expect(readFileSync(path, "utf8")).toContain('"api_key": "padded-key"')
+
+ for (const empty of ["", " ", "\t\n"]) {
+ const exit = await exitIn(layer, (store) => store.save(empty))
+ expect(failureMessage(exit)).toContain("API key cannot be empty")
+ }
+ })
+
+ test("loading a nonexistent file yields empty credentials, not an error", async () => {
+ const dir = tempDir()
+ const credentials = await runIn(storeAt(dir), (store) => store.load)
+ expect(credentials).toEqual({
+ key: "",
+ source: "",
+ oauth: undefined,
+ path: join(dir, "auth.json")
+ })
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestAPIKeyAndOAuthCoexistAndUpdateIndependently
+// ---------------------------------------------------------------------------
+
+describe("TestAPIKeyAndOAuthCoexistAndUpdateIndependently", () => {
+ test("api_key and oauth update without clobbering each other", async () => {
+ const layer = storeAt(tempDir())
+
+ await runIn(layer, (store) => store.save("api-secret"))
+ await runIn(layer, (store) =>
+ store.saveOAuth(
+ oauth({
+ clientId: "desktop-id",
+ clientSecret: "client-secret",
+ accessToken: "access-secret",
+ refreshToken: "refresh-secret",
+ scopes: ["scope.one", "scope.two"]
+ })
+ )
+ )
+
+ let credentials = await runIn(layer, (store) => store.load)
+ expect(credentials.key).toBe("api-secret")
+ expect(credentials.oauth?.clientId).toBe("desktop-id")
+ expect(credentials.oauth?.refreshToken).toBe("refresh-secret")
+ expect(credentials.oauth?.scopes).toEqual(["scope.one", "scope.two"])
+
+ await runIn(layer, (store) => store.save("replacement-key"))
+ credentials = await runIn(layer, (store) => store.load)
+ expect(credentials.oauth?.accessToken).toBe("access-secret")
+
+ await runIn(layer, (store) => store.clearOAuth)
+ credentials = await runIn(layer, (store) => store.load)
+ expect(credentials.key).toBe("replacement-key")
+ expect(credentials.oauth).toBeUndefined()
+ // A cleared oauth block is OMITTED, not written as null.
+ expect(readFileSync(credentials.path, "utf8")).toBe('{\n "api_key": "replacement-key"\n}\n')
+ })
+
+ test("clearOAuth on a file with no oauth is a no-op that still succeeds", async () => {
+ const layer = storeAt(tempDir())
+ await runIn(layer, (store) => store.save("k"))
+ await runIn(layer, (store) => store.clearOAuth)
+ expect((await runIn(layer, (store) => store.load)).key).toBe("k")
+ })
+
+ test("normalizeOAuth trims the five strings and enforces both rules", async () => {
+ const layer = storeAt(tempDir())
+
+ const path = await runIn(layer, (store) =>
+ store.saveOAuth(
+ oauth({
+ clientId: " id ",
+ clientSecret: " secret ",
+ accessToken: " access ",
+ refreshToken: " refresh ",
+ expiry: " 2026-02-01T12:00:00Z "
+ })
+ )
+ )
+ const text = readFileSync(path, "utf8")
+ expect(text).toContain('"client_id": "id"')
+ expect(text).toContain('"expiry": "2026-02-01T12:00:00Z"')
+
+ const missingClient = await exitIn(layer, (store) =>
+ store.saveOAuth(oauth({ clientId: " " }))
+ )
+ expect(failureMessage(missingClient)).toContain(
+ "OAuth client ID and client secret cannot be empty"
+ )
+
+ const missingSecret = await exitIn(layer, (store) =>
+ store.saveOAuth(oauth({ clientSecret: "" }))
+ )
+ expect(failureMessage(missingSecret)).toContain(
+ "OAuth client ID and client secret cannot be empty"
+ )
+
+ const missingTokens = await exitIn(layer, (store) =>
+ store.saveOAuth(oauth({ accessToken: " ", refreshToken: "" }))
+ )
+ expect(failureMessage(missingTokens)).toContain(
+ "OAuth access token or refresh token is required"
+ )
+
+ // Either token alone is sufficient.
+ await runIn(layer, (store) => store.saveOAuth(oauth({ accessToken: "", refreshToken: "r" })))
+ await runIn(layer, (store) => store.saveOAuth(oauth({ accessToken: "a", refreshToken: "" })))
+ })
+
+ test("scopes are not validated or normalized", async () => {
+ const layer = storeAt(tempDir())
+ const path = await runIn(layer, (store) =>
+ store.saveOAuth(oauth({ scopes: [" padded ", ""] }))
+ )
+ expect(readFileSync(path, "utf8")).toContain('" padded "')
+ })
+
+ test("an empty scopes array serializes as null, matching Go's cloneOAuth", async () => {
+ // Go's `append([]string(nil), empty...)` returns nil, so an empty slice
+ // and a nil slice are indistinguishable once stored.
+ const layer = storeAt(tempDir())
+ const path = await runIn(layer, (store) => store.saveOAuth(oauth({ scopes: [] })))
+ expect(readFileSync(path, "utf8")).toContain('"scopes": null')
+ expect((await runIn(layer, (store) => store.load)).oauth?.scopes).toEqual([])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestOAuthBootstrapEnvironmentPrecedence
+// ---------------------------------------------------------------------------
+
+describe("TestOAuthBootstrapEnvironmentPrecedence", () => {
+ test("returns the trimmed bootstrap variables", async () => {
+ const pair = await runIn(
+ storeAt(tempDir(), {
+ OYTC_OAUTH_CLIENT_ID: " environment-id ",
+ OYTC_OAUTH_CLIENT_SECRET: " environment-secret "
+ }),
+ (store) => store.oauthBootstrap
+ )
+ expect(pair).toEqual(["environment-id", "environment-secret"])
+ })
+
+ test("unset variables come back as empty strings", async () => {
+ expect(await runIn(storeAt(tempDir()), (store) => store.oauthBootstrap)).toEqual(["", ""])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestConcurrentUpdatesAreNotLost — shape (a), concurrent fibers
+// ---------------------------------------------------------------------------
+
+describe("TestConcurrentUpdatesAreNotLost (in-process fibers)", () => {
+ test("a concurrent api-key save and oauth save both survive", async () => {
+ const dir = tempDir()
+ const layer = storeAt(dir)
+
+ await Effect.runPromise(
+ Effect.gen(function* () {
+ const store = yield* CredentialStore
+ yield* Effect.all([store.save("api-secret"), store.saveOAuth(oauth())], {
+ concurrency: "unbounded"
+ })
+ }).pipe(Effect.provide(layer)) as Effect.Effect
+ )
+
+ const credentials = await runIn(layer, (store) => store.load)
+ expect(credentials.key).toBe("api-secret")
+ expect(credentials.oauth?.refreshToken).toBe("refresh")
+ })
+
+ test("many concurrent writers all land — none is silently dropped", async () => {
+ // Without the read-modify-write lock, the last rename wins and every
+ // earlier writer's field vanishes.
+ const layer = storeAt(tempDir())
+ const writers = 12
+
+ await Effect.runPromise(
+ Effect.gen(function* () {
+ const store = yield* CredentialStore
+ yield* Effect.all(
+ Array.from({ length: writers }, (_unused, i) =>
+ i % 2 === 0
+ ? Effect.asVoid(store.save(`key-${i}`))
+ : Effect.asVoid(store.saveOAuth(oauth({ accessToken: `access-${i}` })))
+ ),
+ { concurrency: "unbounded" }
+ )
+ }).pipe(Effect.provide(layer)) as Effect.Effect
+ )
+
+ const credentials = await runIn(layer, (store) => store.load)
+ // Which writer won each field is a race, but BOTH fields must be present:
+ // that is what "no update was lost" means.
+ expect(credentials.key).toMatch(/^key-\d+$/)
+ expect(credentials.oauth?.accessToken).toMatch(/^access-\d+$/)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestConcurrentUpdatesAreNotLost — shape (b), two spawned bun processes.
+// This is the shape that actually proves cross-process safety and has no
+// counterpart in the Go suite (goroutines share one flock file description).
+// ---------------------------------------------------------------------------
+
+const WORKER = join(import.meta.dir, "credentialStore.worker.ts")
+
+const spawnWorker = async (
+ configDir: string,
+ mode: string,
+ iterations: string
+): Promise<{ readonly code: number; readonly stderr: string }> => {
+ const proc = Bun.spawn(["bun", "run", WORKER, configDir, mode, iterations], {
+ stdout: "pipe",
+ stderr: "pipe",
+ env: { ...process.env, OYTC_API_KEY: "" }
+ })
+ const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()])
+ return { code, stderr }
+}
+
+describe("TestConcurrentUpdatesAreNotLost (two spawned bun subprocesses)", () => {
+ test(
+ "an api-key writer and an oauth writer in SEPARATE processes both survive",
+ async () => {
+ const dir = tempDir()
+
+ const [keyWorker, oauthWorker] = await Promise.all([
+ spawnWorker(dir, "key", "25"),
+ spawnWorker(dir, "oauth", "25")
+ ])
+
+ expect(keyWorker.stderr).toBe("")
+ expect(oauthWorker.stderr).toBe("")
+ expect(keyWorker.code).toBe(0)
+ expect(oauthWorker.code).toBe(0)
+
+ const credentials = await runIn(storeAt(dir), (store) => store.load)
+ // Each process hammered its own field 25 times while the other did the
+ // same. With only an atomic rename and no lock, one field would be gone.
+ expect(credentials.key).toBe("api-secret-24")
+ expect(credentials.oauth?.accessToken).toBe("access-24")
+ expect(credentials.oauth?.refreshToken).toBe("refresh")
+ expect(credentials.oauth?.scopes).toEqual(["scope"])
+ },
+ 60_000
+ )
+
+ test(
+ "the file is never observed torn or truncated by a concurrent reader",
+ async () => {
+ // A reader process parsing auth.json while two writers race proves the
+ // temp-file + fsync + rename sequence really is atomic to readers.
+ const dir = tempDir()
+ const results = await Promise.all([
+ spawnWorker(dir, "key", "20"),
+ spawnWorker(dir, "oauth", "20"),
+ spawnWorker(dir, "read", "60")
+ ])
+ for (const result of results) {
+ expect(result.stderr).toBe("")
+ expect(result.code).toBe(0)
+ }
+ },
+ 60_000
+ )
+})
+
+// ---------------------------------------------------------------------------
+// TestRefreshedOAuthDoesNotRestoreRemovedCredentials
+// ---------------------------------------------------------------------------
+
+describe("TestRefreshedOAuthDoesNotRestoreRemovedCredentials", () => {
+ test("a stale refresh does not resurrect credentials removed by logout", async () => {
+ const layer = storeAt(tempDir())
+ const initial = oauth({ accessToken: "old-access" })
+
+ await runIn(layer, (store) => store.saveOAuth(initial))
+ const removed = await runIn(layer, (store) => store.remove)
+ expect(removed.removed).toBe(true)
+
+ const updated = oauth({ accessToken: "new-access", expiry: "2026-02-01T13:00:00Z" })
+ const saved = await runIn(layer, (store) => store.saveRefreshedOAuth(initial, updated))
+
+ // No write, no error — the whole point of the compare-and-swap.
+ expect(saved).toBe(false)
+ expect(existsSync(removed.path)).toBe(false)
+ })
+
+ test("a matching expectation DOES write and preserves the api_key", async () => {
+ const layer = storeAt(tempDir())
+ const initial = oauth({ accessToken: "old-access" })
+ await runIn(layer, (store) => store.save("keep-me"))
+ await runIn(layer, (store) => store.saveOAuth(initial))
+
+ const updated = oauth({ accessToken: "new-access", expiry: "2026-02-01T13:00:00Z" })
+ expect(await runIn(layer, (store) => store.saveRefreshedOAuth(initial, updated))).toBe(true)
+
+ const credentials = await runIn(layer, (store) => store.load)
+ expect(credentials.oauth?.accessToken).toBe("new-access")
+ expect(credentials.oauth?.expiry).toBe("2026-02-01T13:00:00Z")
+ // The api_key is re-read inside the lock and carried across.
+ expect(credentials.key).toBe("keep-me")
+ })
+
+ test.each([
+ ["clientId", { clientId: "different" }],
+ ["clientSecret", { clientSecret: "different" }],
+ ["accessToken", { accessToken: "different" }],
+ ["refreshToken", { refreshToken: "different" }],
+ ["expiry", { expiry: "different" }],
+ ["scope count", { scopes: ["scope", "extra"] }],
+ ["scope order", { scopes: ["b", "a"] }]
+ ] as ReadonlyArray]>)(
+ "a mismatched %s blocks the write",
+ async (_name, patch) => {
+ const layer = storeAt(tempDir())
+ const stored = oauth({ scopes: ["a", "b"] })
+ await runIn(layer, (store) => store.saveOAuth(stored))
+
+ const expected = { ...stored, ...patch }
+ const updated = oauth({ accessToken: "new-access", scopes: ["a", "b"] })
+ expect(await runIn(layer, (store) => store.saveRefreshedOAuth(expected, updated))).toBe(
+ false
+ )
+
+ // The stored block is untouched.
+ const credentials = await runIn(layer, (store) => store.load)
+ expect(credentials.oauth?.accessToken).toBe("access")
+ }
+ )
+
+ test("a login that replaced the credentials mid-refresh also blocks the write", async () => {
+ const layer = storeAt(tempDir())
+ const original = oauth({ accessToken: "original" })
+ await runIn(layer, (store) => store.saveOAuth(original))
+
+ // A new `login` lands while the refresh is in flight.
+ await runIn(layer, (store) => store.saveOAuth(oauth({ accessToken: "from-new-login" })))
+
+ const refreshed = oauth({ accessToken: "refreshed-from-original" })
+ expect(await runIn(layer, (store) => store.saveRefreshedOAuth(original, refreshed))).toBe(
+ false
+ )
+ expect((await runIn(layer, (store) => store.load)).oauth?.accessToken).toBe("from-new-login")
+ })
+
+ test("expecting undefined against an empty file writes", async () => {
+ const layer = storeAt(tempDir())
+ const next = oauth()
+ expect(await runIn(layer, (store) => store.saveRefreshedOAuth(undefined, next))).toBe(true)
+ expect((await runIn(layer, (store) => store.load)).oauth?.accessToken).toBe("access")
+ })
+
+ test("expecting undefined against a populated file does not write", async () => {
+ const layer = storeAt(tempDir())
+ await runIn(layer, (store) => store.saveOAuth(oauth({ accessToken: "stored" })))
+ expect(
+ await runIn(layer, (store) => store.saveRefreshedOAuth(undefined, oauth({ accessToken: "x" })))
+ ).toBe(false)
+ expect((await runIn(layer, (store) => store.load)).oauth?.accessToken).toBe("stored")
+ })
+
+ test("the replacement is normalized before the compare", async () => {
+ const layer = storeAt(tempDir())
+ const exit = await exitIn(layer, (store) =>
+ store.saveRefreshedOAuth(undefined, oauth({ clientId: " " }))
+ )
+ expect(failureMessage(exit)).toContain("OAuth client ID and client secret cannot be empty")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestLoadFallsBackToEnvironmentKeyWhenFileCorrupt
+// ---------------------------------------------------------------------------
+
+describe("TestLoadFallsBackToEnvironmentKeyWhenFileCorrupt", () => {
+ test("a corrupt auth.json falls back to OYTC_API_KEY without an error", async () => {
+ const dir = tempDir()
+ writeFileSync(join(dir, "auth.json"), "{not json", { mode: 0o600 })
+
+ const credentials = await runIn(
+ storeAt(dir, { OYTC_API_KEY: "environment-secret" }),
+ (store) => store.load
+ )
+ expect(credentials.key).toBe("environment-secret")
+ expect(credentials.source).toBe("OYTC_API_KEY")
+ // A corrupt file yields no OAuth: nothing could be parsed out of it.
+ expect(credentials.oauth).toBeUndefined()
+ })
+
+ test("without the env key, a corrupt file is a parse error", async () => {
+ const dir = tempDir()
+ writeFileSync(join(dir, "auth.json"), "{not json", { mode: 0o600 })
+ const exit = await exitIn(storeAt(dir), (store) => store.load)
+ expect(failureMessage(exit)).toContain("parse credentials")
+ })
+
+ test("a type error inside a well-formed file is also a parse error", async () => {
+ const dir = tempDir()
+ writeFileSync(join(dir, "auth.json"), '{"api_key":123}', { mode: 0o600 })
+ const exit = await exitIn(storeAt(dir), (store) => store.load)
+ expect(failureMessage(exit)).toContain("parse credentials")
+ })
+
+ test("a whitespace-only env key does NOT rescue a corrupt file", async () => {
+ const dir = tempDir()
+ writeFileSync(join(dir, "auth.json"), "{not json", { mode: 0o600 })
+ const exit = await exitIn(storeAt(dir, { OYTC_API_KEY: " " }), (store) => store.load)
+ expect(failureMessage(exit)).toContain("parse credentials")
+ })
+
+ test("mutations stay STRICT on a corrupt file even with the env key set", async () => {
+ // Falling back on a mutation would rewrite the file from an empty
+ // in-memory File and silently destroy the user's stored OAuth block.
+ const dir = tempDir()
+ const path = join(dir, "auth.json")
+ writeFileSync(path, "{not json", { mode: 0o600 })
+
+ const layer = storeAt(dir, { OYTC_API_KEY: "environment-secret" })
+ const exit = await exitIn(layer, (store) => store.save("new-key"))
+ expect(failureMessage(exit)).toContain("parse credentials")
+ expect(readFileSync(path, "utf8")).toBe("{not json")
+ })
+
+ test("a directory where auth.json should be is a read error, not a parse error", async () => {
+ const dir = tempDir()
+ const layer = storeAt(dir)
+ // Force the path to be a directory.
+ await runIn(layer, (store) => store.path)
+ const { mkdirSync } = await import("node:fs")
+ mkdirSync(join(dir, "auth.json"))
+ const exit = await exitIn(layer, (store) => store.load)
+ expect(failureMessage(exit)).toContain("read credentials")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestEnvironmentKeyHasPrecedence
+// ---------------------------------------------------------------------------
+
+describe("TestEnvironmentKeyHasPrecedence", () => {
+ test("OYTC_API_KEY overrides the stored key", async () => {
+ const dir = tempDir()
+ await runIn(storeAt(dir), (store) => store.save("file-secret"))
+
+ const credentials = await runIn(
+ storeAt(dir, { OYTC_API_KEY: "environment-secret" }),
+ (store) => store.load
+ )
+ expect(credentials.key).toBe("environment-secret")
+ expect(credentials.source).toBe("OYTC_API_KEY")
+ })
+
+ test("the env key is trimmed", async () => {
+ const credentials = await runIn(
+ storeAt(tempDir(), { OYTC_API_KEY: " environment-secret " }),
+ (store) => store.load
+ )
+ expect(credentials.key).toBe("environment-secret")
+ })
+
+ test("a whitespace-only env key is treated as unset", async () => {
+ const dir = tempDir()
+ await runIn(storeAt(dir), (store) => store.save("file-secret"))
+ const credentials = await runIn(storeAt(dir, { OYTC_API_KEY: " " }), (store) => store.load)
+ expect(credentials.key).toBe("file-secret")
+ expect(credentials.source).toBe("auth.json")
+ })
+
+ test("the env key never clears stored OAuth", async () => {
+ const dir = tempDir()
+ await runIn(storeAt(dir), (store) => store.saveOAuth(oauth()))
+ const credentials = await runIn(
+ storeAt(dir, { OYTC_API_KEY: "environment-secret" }),
+ (store) => store.load
+ )
+ expect(credentials.key).toBe("environment-secret")
+ expect(credentials.oauth?.clientId).toBe("id")
+ })
+
+ test("an oauth-only file leaves source empty", async () => {
+ const dir = tempDir()
+ await runIn(storeAt(dir), (store) => store.saveOAuth(oauth()))
+ const credentials = await runIn(storeAt(dir), (store) => store.load)
+ expect(credentials.key).toBe("")
+ expect(credentials.source).toBe("")
+ expect(credentials.oauth).toBeDefined()
+ })
+
+ test("envKeySet mirrors the trimmed-nonempty test", async () => {
+ expect(await runIn(storeAt(tempDir(), { OYTC_API_KEY: "k" }), (s) => s.envKeySet)).toBe(true)
+ expect(await runIn(storeAt(tempDir(), { OYTC_API_KEY: " " }), (s) => s.envKeySet)).toBe(false)
+ expect(await runIn(storeAt(tempDir()), (s) => s.envKeySet)).toBe(false)
+ })
+
+ test("the stored key is trimmed on load", async () => {
+ const dir = tempDir()
+ writeFileSync(join(dir, "auth.json"), '{"api_key":" padded "}', { mode: 0o600 })
+ expect((await runIn(storeAt(dir), (store) => store.load)).key).toBe("padded")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestFingerprintDoesNotExposeKey
+// ---------------------------------------------------------------------------
+
+describe("TestFingerprintDoesNotExposeKey", () => {
+ test("the fingerprint is a 19-character prefixed digest that leaks nothing", () => {
+ const key = "this-is-a-secret-key"
+ const value = fingerprint(key)
+ expect(value.startsWith("sha256:")).toBe(true)
+ expect(value.includes(key)).toBe(false)
+ expect(value.length).toBe("sha256:".length + 12)
+ })
+
+ test.each([
+ // Reference values captured from Go's crypto/sha256 + encoding/hex.
+ ["this-is-a-secret-key", "sha256:e0c2f4e37886"],
+ ["test-secret-key", "sha256:2ceac6f36363"]
+ ])("fingerprint(%s) matches Go", (key, expected) => {
+ expect(fingerprint(key)).toBe(expected)
+ })
+
+ test("an empty or whitespace-only key fingerprints to the empty string", () => {
+ expect(fingerprint("")).toBe("")
+ expect(fingerprint(" ")).toBe("")
+ })
+
+ test("the key is trimmed before hashing", () => {
+ expect(fingerprint(" test-secret-key ")).toBe(fingerprint("test-secret-key"))
+ })
+
+ test("the service exposes the same function", async () => {
+ const value = await runIn(storeAt(tempDir()), (store) =>
+ Effect.succeed(store.fingerprint("test-secret-key"))
+ )
+ expect(value).toBe("sha256:2ceac6f36363")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Remove
+// ---------------------------------------------------------------------------
+
+describe("remove", () => {
+ test("creates the config directory when it does not exist yet", async () => {
+ const root = tempDir()
+ const configDir = join(root, "never-created")
+ const result = await runIn(storeAt(configDir), (store) => store.remove)
+ expect(result.removed).toBe(false)
+ // The lockfile has to live somewhere, so the dir is created at 0700.
+ expect(perm(configDir)).toBe(0o700)
+ })
+
+ test("a concurrent save cannot recreate credentials after removal", async () => {
+ // Go takes the update lock inside Remove for exactly this reason.
+ const dir = tempDir()
+ const layer = storeAt(dir)
+ await runIn(layer, (store) => store.save("pre-existing"))
+
+ const outcome = await Effect.runPromise(
+ Effect.gen(function* () {
+ const store = yield* CredentialStore
+ const [saved, removed] = yield* Effect.all(
+ [store.save("racing-write"), store.remove],
+ { concurrency: "unbounded" }
+ )
+ return { saved, removed }
+ }).pipe(Effect.provide(layer)) as Effect.Effect<{
+ readonly saved: string
+ readonly removed: { readonly path: string; readonly removed: boolean }
+ }>
+ )
+
+ // Either order is legal; what is NOT legal is a half-written file. Both
+ // operations were serialized, so the end state is one of two clean states.
+ const path = outcome.removed.path
+ if (existsSync(path)) {
+ // remove ran first, then save recreated the file cleanly.
+ expect(readFileSync(path, "utf8")).toBe('{\n "api_key": "racing-write"\n}\n')
+ } else {
+ expect(outcome.removed.removed).toBe(true)
+ }
+ })
+})
diff --git a/src/impl/credentialStore.ts b/src/impl/credentialStore.ts
index ca14f9a..d37dddc 100644
--- a/src/impl/credentialStore.ts
+++ b/src/impl/credentialStore.ts
@@ -1,2 +1,410 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Config directory resolution and credential storage — Go's `internal/config`.
+ *
+ * Three behaviors carry real weight and are called out where they are
+ * implemented:
+ *
+ * - `load` treats a CORRUPT auth.json as recoverable when `OYTC_API_KEY` is
+ * set. A broken file must never lock a user out of the higher-precedence
+ * env key. Every *mutating* path keeps the strict behavior: silently
+ * rewriting a file you failed to parse would destroy credentials.
+ * - Every mutation is a lock -> read -> mutate -> atomic-write cycle. The
+ * atomic rename alone is not enough: two concurrent updates would each read
+ * the old file and the later rename would silently drop the earlier one.
+ * - `saveRefreshedOAuth` is a compare-and-swap over all six OAuth fields. A
+ * token refresh that started before a `logout` must not resurrect the
+ * credentials the user just deleted.
+ */
+
+import { Effect, FileSystem, Layer, Option, Path, Result } from "effect"
+import { createHash } from "node:crypto"
+import { OperationalError } from "../domain/errors.ts"
+import { parseJson } from "../json/parse.ts"
+import {
+ cloneOAuth,
+ decodeAuthFile,
+ emptyAuthFile,
+ encodeAuthFile,
+ sameOAuth,
+ type AuthFile,
+ type AuthOAuth
+} from "../schema/authfile.ts"
+import {
+ CredentialStore,
+ FileLock,
+ ProcessEnv,
+ type CredentialStoreShape,
+ type Credentials,
+ type StoredOAuth
+} from "../services/index.ts"
+import { atomicWriteSecure, ensureSecureDirectory } from "./atomicWrite.ts"
+
+const ENV_KEY = "OYTC_API_KEY"
+const ENV_CONFIG_DIR = "OYTC_CONFIG_DIR"
+const ENV_OAUTH_CLIENT_ID = "OYTC_OAUTH_CLIENT_ID"
+const ENV_OAUTH_CLIENT_SECRET = "OYTC_OAUTH_CLIENT_SECRET"
+const ENV_XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
+const ENV_APPDATA = "APPDATA"
+
+const AUTH_FILE = "auth.json"
+const LOCK_FILE = ".auth.lock"
+
+/** Go's `strings.TrimSpace`, which trims Unicode whitespace on both ends. */
+const trimSpace = (value: string): string => value.trim()
+
+/** `os.Getenv`: an unset variable and an empty one are indistinguishable. */
+const getenv = (env: Option.Option): string => Option.getOrElse(env, () => "")
+
+const isNotFound = (error: unknown): boolean =>
+ typeof error === "object" &&
+ error !== null &&
+ "reason" in error &&
+ typeof (error as { readonly reason: unknown }).reason === "object" &&
+ (error as { readonly reason: { readonly _tag?: unknown } }).reason?._tag === "NotFound"
+
+const errorText = (cause: unknown): string =>
+ cause instanceof Error ? cause.message : String(cause)
+
+// ---------------------------------------------------------------------------
+// StoredOAuth <-> AuthOAuth
+// ---------------------------------------------------------------------------
+
+/**
+ * The service contract flattens Go's nil-vs-empty scope slice to a plain array.
+ * That loses nothing: Go's `cloneOAuth` runs `append([]string(nil), s...)`,
+ * which returns nil for an empty input, so Go can never *write* `[]` either —
+ * both directions collapse to `null` on disk.
+ */
+const toStored = (oauth: AuthOAuth): StoredOAuth => ({
+ clientId: oauth.clientId,
+ clientSecret: oauth.clientSecret,
+ accessToken: oauth.accessToken,
+ refreshToken: oauth.refreshToken,
+ expiry: oauth.expiry,
+ scopes: oauth.scopes === undefined ? [] : [...oauth.scopes]
+})
+
+const fromStored = (oauth: StoredOAuth): AuthOAuth => ({
+ clientId: oauth.clientId,
+ clientSecret: oauth.clientSecret,
+ accessToken: oauth.accessToken,
+ refreshToken: oauth.refreshToken,
+ expiry: oauth.expiry,
+ scopes: oauth.scopes.length === 0 ? undefined : [...oauth.scopes]
+})
+
+/** Go's `normalizeOAuth`: trim the five strings, then two validity rules. */
+const normalizeOAuth = (
+ oauth: StoredOAuth
+): Effect.Effect => {
+ const normalized: AuthOAuth = {
+ clientId: trimSpace(oauth.clientId),
+ clientSecret: trimSpace(oauth.clientSecret),
+ accessToken: trimSpace(oauth.accessToken),
+ refreshToken: trimSpace(oauth.refreshToken),
+ expiry: trimSpace(oauth.expiry),
+ // Scopes are neither validated nor normalized.
+ scopes: oauth.scopes.length === 0 ? undefined : [...oauth.scopes]
+ }
+ if (normalized.clientId === "" || normalized.clientSecret === "") {
+ return Effect.fail(
+ new OperationalError({ message: "OAuth client ID and client secret cannot be empty" })
+ )
+ }
+ if (normalized.accessToken === "" && normalized.refreshToken === "") {
+ return Effect.fail(
+ new OperationalError({ message: "OAuth access token or refresh token is required" })
+ )
+ }
+ return Effect.succeed(cloneOAuth(normalized))
+}
+
+// ---------------------------------------------------------------------------
+// Fingerprint
+// ---------------------------------------------------------------------------
+
+/** `"sha256:" + hex(sha256(key)).slice(0, 12)`; `""` for an empty key. */
+export const fingerprint = (key: string): string => {
+ const trimmed = trimSpace(key)
+ if (trimmed === "") return ""
+ return `sha256:${createHash("sha256").update(trimmed, "utf8").digest("hex").slice(0, 12)}`
+}
+
+// ---------------------------------------------------------------------------
+// Service
+// ---------------------------------------------------------------------------
+
+export const makeCredentialStore: Effect.Effect<
+ CredentialStoreShape,
+ never,
+ | FileSystem.FileSystem
+ | Path.Path
+ | (typeof ProcessEnv)["Identifier"]
+ | (typeof FileLock)["Identifier"]
+> = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const processEnv = yield* ProcessEnv
+ const lock = yield* FileLock
+
+ const env = (name: string): string => getenv(processEnv.env(name))
+
+ const homeDir = processEnv.homeDir.pipe(
+ Effect.catch((cause) =>
+ Effect.fail(
+ new OperationalError({
+ message: `determine config directory: ${cause.message}`,
+ cause
+ })
+ )
+ )
+ )
+
+ /**
+ * Go's `expandHome`, applied ONLY to `OYTC_CONFIG_DIR`. Expands exactly `~`,
+ * `~/…`, or `~\…` (backslash for Windows). `~user` is NOT supported: it
+ * starts with neither prefix, so it is returned untouched. An unresolvable
+ * home also returns the path unchanged.
+ */
+ const expandHome = (value: string): Effect.Effect => {
+ if (value !== "~" && !value.startsWith("~/") && !value.startsWith("~\\")) {
+ return Effect.succeed(value)
+ }
+ return homeDir.pipe(
+ Effect.map((home) => (value.length === 1 ? home : path.join(home, value.slice(2)))),
+ Effect.catchCause(() => Effect.succeed(value))
+ )
+ }
+
+ const dir: Effect.Effect = Effect.gen(function* () {
+ // The override IS the directory: no "oytc" component is appended.
+ const override = trimSpace(env(ENV_CONFIG_DIR))
+ if (override !== "") return yield* expandHome(override)
+
+ if (processEnv.platform === "darwin") {
+ const home = yield* homeDir
+ return path.join(home, "Library", "Application Support", "oytc")
+ }
+
+ if (processEnv.platform === "win32") {
+ const appData = env(ENV_APPDATA)
+ if (appData === "") {
+ return yield* Effect.fail(
+ new OperationalError({ message: "determine config directory: APPDATA is not set" })
+ )
+ }
+ return path.join(appData, "oytc")
+ }
+
+ // NOT trimmed — only an exactly-empty XDG_CONFIG_HOME counts as unset.
+ const xdg = env(ENV_XDG_CONFIG_HOME)
+ const base = xdg !== "" ? xdg : path.join(yield* homeDir, ".config")
+ return path.join(base, "oytc")
+ })
+
+ const filePath: Effect.Effect = Effect.map(dir, (d) =>
+ path.join(d, AUTH_FILE)
+ )
+
+ const lockPathFor = (authPath: string): string =>
+ path.join(path.dirname(authPath), LOCK_FILE)
+
+ /**
+ * Read + parse `auth.json`. A missing file is `exists: false` with no error;
+ * a read or parse failure is an error carrying Go's message prefix.
+ */
+ interface LoadedFile {
+ readonly file: AuthFile
+ readonly exists: boolean
+ }
+
+ const loadFile = (authPath: string): Effect.Effect =>
+ Effect.gen(function* () {
+ const text: string | undefined = yield* fs.readFileString(authPath).pipe(
+ Effect.catch((cause) =>
+ isNotFound(cause)
+ ? Effect.succeed(undefined)
+ : Effect.fail(
+ new OperationalError({ message: `read credentials: ${errorText(cause)}`, cause })
+ )
+ )
+ )
+ if (text === undefined) return { file: emptyAuthFile, exists: false }
+
+ const parsed = parseJson(text)
+ if (Result.isFailure(parsed)) {
+ return yield* Effect.fail(
+ new OperationalError({ message: `parse credentials: ${parsed.failure.message}` })
+ )
+ }
+ const decoded = decodeAuthFile(parsed.success)
+ if (Result.isFailure(decoded)) {
+ return yield* Effect.fail(
+ new OperationalError({ message: `parse credentials: ${decoded.failure.message}` })
+ )
+ }
+ return { file: decoded.success, exists: true }
+ })
+
+ const saveFile = (
+ authPath: string,
+ file: AuthFile
+ ): Effect.Effect =>
+ atomicWriteSecure(fs, path, authPath, encodeAuthFile(file))
+
+ /**
+ * lock -> read -> mutate -> atomic write. The lock spans the whole cycle;
+ * dropping it between the read and the write is exactly the race that loses
+ * a concurrent update.
+ */
+ const updateFile = (
+ mutate: (file: AuthFile) => AuthFile
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const authPath = yield* filePath
+ return yield* lock.withLock(
+ lockPathFor(authPath),
+ Effect.gen(function* () {
+ // Deliberately strict: a parse failure aborts rather than falling
+ // back, so a mutation never silently discards an unreadable file.
+ const { file } = yield* loadFile(authPath)
+ return yield* saveFile(authPath, mutate(file))
+ })
+ )
+ })
+
+ const load: Effect.Effect = Effect.gen(function* () {
+ const authPath = yield* filePath
+ const envKey = trimSpace(env(ENV_KEY))
+
+ const loaded = yield* Effect.result(loadFile(authPath))
+ if (Result.isFailure(loaded)) {
+ // A corrupt auth.json must not block the higher-precedence env key.
+ if (envKey !== "") {
+ const fallback: Credentials = {
+ key: envKey,
+ source: "OYTC_API_KEY",
+ oauth: undefined,
+ path: authPath
+ }
+ return fallback
+ }
+ return yield* Effect.fail(loaded.failure)
+ }
+
+ const { file, exists } = loaded.success
+ let key = ""
+ let source: Credentials["source"] = ""
+ let oauth: StoredOAuth | undefined
+
+ if (exists) {
+ key = trimSpace(file.apiKey)
+ oauth = file.oauth === undefined ? undefined : toStored(cloneOAuth(file.oauth))
+ if (key !== "") source = "auth.json"
+ }
+ if (envKey !== "") {
+ key = envKey
+ source = "OYTC_API_KEY"
+ }
+ // Note the env key never clears stored OAuth: both are returned together.
+ const credentials: Credentials = { key, source, oauth, path: authPath }
+ return credentials
+ })
+
+ const save = (key: string): Effect.Effect => {
+ const trimmed = trimSpace(key)
+ if (trimmed === "") {
+ return Effect.fail(new OperationalError({ message: "API key cannot be empty" }))
+ }
+ return updateFile((file) => ({ ...file, apiKey: trimmed }))
+ }
+
+ const saveOAuth = (credentials: StoredOAuth): Effect.Effect =>
+ Effect.flatMap(normalizeOAuth(credentials), (normalized) =>
+ updateFile((file) => ({ ...file, oauth: cloneOAuth(normalized) }))
+ )
+
+ const saveRefreshedOAuth = (
+ expected: StoredOAuth | undefined,
+ next: StoredOAuth
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const normalized = yield* normalizeOAuth(next)
+ const authPath = yield* filePath
+ const expectedOAuth = expected === undefined ? undefined : fromStored(expected)
+
+ return yield* lock.withLock(
+ lockPathFor(authPath),
+ Effect.gen(function* () {
+ const { file } = yield* loadFile(authPath)
+ // Compare-and-swap. A mismatch — including a file removed by a
+ // concurrent `logout` — writes NOTHING and reports false, not an
+ // error. This is what stops a refresh from resurrecting credentials.
+ if (!sameOAuth(file.oauth, expectedOAuth)) return false
+ yield* saveFile(authPath, { apiKey: file.apiKey, oauth: cloneOAuth(normalized) })
+ return true
+ })
+ )
+ })
+
+ const clearOAuth: Effect.Effect = updateFile((file) => ({
+ ...file,
+ oauth: undefined
+ }))
+
+ interface RemoveResult {
+ readonly path: string
+ readonly removed: boolean
+ }
+
+ const remove: Effect.Effect = Effect.gen(function* () {
+ const authPath = yield* filePath
+ const lockPath = lockPathFor(authPath)
+ // The lockfile lives in the config dir, so the dir has to exist before the
+ // lock can be taken even when there is nothing to remove.
+ yield* ensureSecureDirectory(fs, path.dirname(authPath))
+
+ // Taking the same lock as saves is deliberate: a concurrent save that has
+ // already read the file must not be able to recreate it after removal.
+ return yield* lock.withLock(
+ lockPath,
+ fs.remove(authPath).pipe(
+ Effect.as({ path: authPath, removed: true }),
+ Effect.catch((cause) =>
+ isNotFound(cause)
+ ? Effect.succeed({ path: authPath, removed: false })
+ : Effect.fail(
+ new OperationalError({
+ message: `remove credentials: ${errorText(cause)}`,
+ cause
+ })
+ )
+ )
+ )
+ )
+ })
+
+ const envKeySet: Effect.Effect = Effect.sync(() => trimSpace(env(ENV_KEY)) !== "")
+
+ const oauthBootstrap: Effect.Effect =
+ Effect.sync(
+ () =>
+ [trimSpace(env(ENV_OAUTH_CLIENT_ID)), trimSpace(env(ENV_OAUTH_CLIENT_SECRET))] as const
+ )
+
+ return {
+ dir,
+ path: filePath,
+ load,
+ save,
+ saveOAuth,
+ saveRefreshedOAuth,
+ clearOAuth,
+ remove,
+ fingerprint,
+ envKeySet,
+ oauthBootstrap
+ }
+})
+
+export const CredentialStoreLive = Layer.effect(CredentialStore, makeCredentialStore)
diff --git a/src/impl/credentialStore.worker.ts b/src/impl/credentialStore.worker.ts
new file mode 100644
index 0000000..3eda42a
--- /dev/null
+++ b/src/impl/credentialStore.worker.ts
@@ -0,0 +1,104 @@
+/**
+ * Test-only worker for the cross-process locking test.
+ *
+ * NOT part of the CLI — nothing imports it, so `bun build --compile` never
+ * reaches it. It exists because the interesting half of the credential lock
+ * cannot be observed from inside one process: Go's flock is per-open-file-
+ * description, so two goroutines contend the same way two processes do, but
+ * this port's in-process `Semaphore` would happily satisfy a same-process test
+ * even if the O_EXCL lockfile were completely broken. Only real subprocesses
+ * prove the cross-process guarantee.
+ *
+ * Usage: `bun run credentialStore.worker.ts `
+ *
+ * key — save "api-secret-", i = 0..n-1
+ * oauth — save an oauth block with accessToken "access-"
+ * read — load n times, asserting the file is never observed torn
+ *
+ * Exits 0 on success. Any failure is written to stderr and exits 1; the test
+ * asserts stderr is empty, so a partial read or a lost update is loud.
+ */
+
+import { Effect, Layer, Option } from "effect"
+import { BunServices } from "@effect/platform-bun"
+import { OperationalError } from "../domain/errors.ts"
+import {
+ CredentialStore,
+ ProcessEnv,
+ type CredentialStoreShape,
+ type ProcessEnvShape,
+ type StoredOAuth
+} from "../services/index.ts"
+import { CredentialStoreLive } from "./credentialStore.ts"
+import { FileLockLive } from "./fileLock.ts"
+
+const [configDir, mode, iterationsText] = process.argv.slice(2)
+
+if (configDir === undefined || mode === undefined || iterationsText === undefined) {
+ process.stderr.write("usage: credentialStore.worker.ts \n")
+ process.exit(1)
+}
+
+const iterations = Number.parseInt(iterationsText, 10)
+
+const processEnv: ProcessEnvShape = {
+ env: (name) => Option.fromNullishOr(name === "OYTC_CONFIG_DIR" ? configDir : undefined),
+ platform: process.platform,
+ arch: process.arch,
+ argv: process.argv,
+ executablePath: Effect.succeed(process.execPath),
+ isOutputTTY: false,
+ homeDir: Effect.fail(new OperationalError({ message: "could not determine home directory" }))
+}
+
+const platform = BunServices.layer as unknown as Layer.Layer
+
+const layer = CredentialStoreLive.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ platform,
+ Layer.succeed(ProcessEnv, processEnv),
+ FileLockLive.pipe(Layer.provide(platform))
+ )
+ )
+) as unknown as Layer.Layer<(typeof CredentialStore)["Identifier"]>
+
+const oauthFor = (index: number): StoredOAuth => ({
+ clientId: "id",
+ clientSecret: "secret",
+ accessToken: `access-${index}`,
+ refreshToken: "refresh",
+ expiry: "2026-02-01T12:00:00Z",
+ scopes: ["scope"]
+})
+
+const step = (store: CredentialStoreShape, index: number): Effect.Effect => {
+ switch (mode) {
+ case "key":
+ return Effect.asVoid(store.save(`api-secret-${index}`))
+ case "oauth":
+ return Effect.asVoid(store.saveOAuth(oauthFor(index)))
+ case "read":
+ // A torn read shows up as a parse error, which `load` surfaces as a
+ // failure (there is no OYTC_API_KEY here to mask it).
+ return Effect.asVoid(store.load)
+ default:
+ return Effect.fail(new Error(`unknown worker mode: ${mode}`))
+ }
+}
+
+const program = Effect.gen(function* () {
+ const store = yield* CredentialStore
+ for (let index = 0; index < iterations; index++) {
+ yield* step(store, index)
+ }
+})
+
+const exit = await Effect.runPromiseExit(
+ program.pipe(Effect.provide(layer)) as Effect.Effect
+)
+
+if (exit._tag === "Failure") {
+ process.stderr.write(`worker ${mode} failed: ${String(exit.cause)}\n`)
+ process.exit(1)
+}
diff --git a/src/impl/fileLock.test.ts b/src/impl/fileLock.test.ts
new file mode 100644
index 0000000..9823981
--- /dev/null
+++ b/src/impl/fileLock.test.ts
@@ -0,0 +1,292 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { Effect, FileSystem, Fiber, Layer, Path } from "effect"
+import { BunServices } from "@effect/platform-bun"
+import { existsSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { FileLock } from "../services/index.ts"
+import { FileLockLive, MAX_STEALS, STALE_MILLIS } from "./fileLock.ts"
+
+const temporaries: Array = []
+
+const tempDir = (): string => {
+ const dir = mkdtempSync(join(tmpdir(), "oytc-lock-"))
+ temporaries.push(dir)
+ return dir
+}
+
+afterEach(() => {
+ while (temporaries.length > 0) {
+ rmSync(temporaries.pop()!, { recursive: true, force: true })
+ }
+})
+
+const platform = BunServices.layer as unknown as Layer.Layer<
+ FileSystem.FileSystem | Path.Path
+>
+
+/**
+ * A FRESH FileLock per program. The in-process semaphore lives inside the
+ * service, so reusing one memoized layer across tests would silently serialize
+ * unrelated cases — and, worse, hide a broken lockfile behind a working
+ * semaphore. Tests that must exercise the O_EXCL path use two layers.
+ */
+const lockLayer = (): Layer.Layer =>
+ Layer.fresh(FileLockLive.pipe(Layer.provide(platform))) as unknown as Layer.Layer<
+ never,
+ never,
+ never
+ >
+
+const runWith = (
+ layer: Layer.Layer,
+ effect: Effect.Effect
+): Promise =>
+ Effect.runPromise(
+ effect.pipe(Effect.provide(layer as unknown as Layer.Layer<(typeof FileLock)["Identifier"]>))
+ )
+
+const run = (
+ effect: Effect.Effect
+): Promise => runWith(lockLayer(), effect)
+
+describe("FileLock", () => {
+ test("runs the effect and returns its value", async () => {
+ const lockPath = join(tempDir(), ".auth.lock")
+ const value = await run(
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ return yield* lock.withLock(lockPath, Effect.succeed(42))
+ })
+ )
+ expect(value).toBe(42)
+ })
+
+ test("creates the lock directory when it does not exist", async () => {
+ const lockPath = join(tempDir(), "deep", "nested", ".auth.lock")
+ await run(
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ return yield* lock.withLock(lockPath, Effect.void)
+ })
+ )
+ expect(existsSync(join(lockPath, ".."))).toBe(true)
+ })
+
+ test("holds the lockfile for the critical section and removes it after", async () => {
+ const lockPath = join(tempDir(), ".auth.lock")
+ let heldDuringSection = false
+ await run(
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ yield* lock.withLock(
+ lockPath,
+ Effect.sync(() => {
+ heldDuringSection = existsSync(lockPath)
+ })
+ )
+ })
+ )
+ expect(heldDuringSection).toBe(true)
+ // Unlike Go's flock sidecar, the O_EXCL lockfile IS the lock, so it must
+ // be unlinked on release or the next acquisition would block for 30 s.
+ expect(existsSync(lockPath)).toBe(false)
+ })
+
+ test("releases the lock when the guarded effect fails", async () => {
+ const lockPath = join(tempDir(), ".auth.lock")
+ const layer = lockLayer()
+ const boom = Effect.gen(function* () {
+ const lock = yield* FileLock
+ return yield* lock.withLock(lockPath, Effect.fail("boom" as const))
+ })
+ await Effect.runPromise(
+ Effect.exit(
+ boom.pipe(
+ Effect.provide(layer as unknown as Layer.Layer<(typeof FileLock)["Identifier"]>)
+ )
+ )
+ )
+ expect(existsSync(lockPath)).toBe(false)
+
+ // And the lock is reusable afterwards.
+ const value = await runWith(
+ layer,
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ return yield* lock.withLock(lockPath, Effect.succeed("second"))
+ })
+ )
+ expect(value).toBe("second")
+ })
+
+ test("releases the lock when the guarded effect is interrupted", async () => {
+ const lockPath = join(tempDir(), ".auth.lock")
+ await run(
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ const fiber = yield* Effect.forkChild(
+ lock.withLock(lockPath, Effect.sleep("30 seconds"))
+ )
+ // Let the fiber reach the critical section before interrupting.
+ yield* Effect.sleep(30)
+ yield* Fiber.interrupt(fiber)
+ })
+ )
+ expect(existsSync(lockPath)).toBe(false)
+ })
+
+ test("a fiber WAITING on a contended lock is still interruptible", async () => {
+ // Regression: `Effect.acquireRelease` runs acquire uninterruptibly by
+ // default, which made the unbounded retry loop unkillable — a second
+ // `oytc` blocked on a lock held by a first would ignore Ctrl-C entirely,
+ // where Go's blocking flock is torn down by the signal.
+ const dir = tempDir()
+ const lockPath = join(dir, ".auth.lock")
+ // A FRESH lockfile held by "someone else": too young to steal, so the
+ // acquire spins forever and the only way out is interruption.
+ writeFileSync(lockPath, "", { mode: 0o600 })
+
+ const interrupted = run(
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ const fiber = yield* Effect.forkChild(lock.withLock(lockPath, Effect.succeed("never")))
+ yield* Effect.sleep(60)
+ yield* Fiber.interrupt(fiber)
+ return "interrupted" as const
+ })
+ )
+
+ const raced = await Promise.race([
+ interrupted,
+ Bun.sleep(3_000).then(() => "hung" as const)
+ ])
+ expect(raced).toBe("interrupted")
+ // The contended lockfile belonged to someone else and must survive.
+ expect(existsSync(lockPath)).toBe(true)
+ })
+
+ test("serializes concurrent fibers in one process", async () => {
+ const lockPath = join(tempDir(), ".auth.lock")
+ let inside = 0
+ let maxInside = 0
+ const order: Array = []
+
+ const section = (name: string) =>
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ yield* lock.withLock(
+ lockPath,
+ Effect.gen(function* () {
+ inside++
+ maxInside = Math.max(maxInside, inside)
+ order.push(`${name}:enter`)
+ yield* Effect.sleep(25)
+ order.push(`${name}:exit`)
+ inside--
+ })
+ )
+ })
+
+ await run(
+ Effect.all([section("a"), section("b"), section("c")], { concurrency: "unbounded" })
+ )
+
+ expect(maxInside).toBe(1)
+ // No interleaving: every enter is immediately followed by its own exit.
+ for (let i = 0; i < order.length; i += 2) {
+ expect(order[i]!.split(":")[0]).toBe(order[i + 1]!.split(":")[0]!)
+ }
+ })
+
+ test("blocks a SECOND lock instance until the first releases (the O_EXCL path)", async () => {
+ // Two service instances share no semaphore, so this exercises the
+ // cross-process mechanism inside one process.
+ const lockPath = join(tempDir(), ".auth.lock")
+ const first = lockLayer()
+ const second = lockLayer()
+ const events: Array = []
+
+ const holder = runWith(
+ first,
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ yield* lock.withLock(
+ lockPath,
+ Effect.gen(function* () {
+ events.push("first:enter")
+ yield* Effect.sleep(120)
+ events.push("first:exit")
+ })
+ )
+ })
+ )
+
+ // Give the holder time to actually take the lock before contending.
+ await Bun.sleep(20)
+
+ const waiter = runWith(
+ second,
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ yield* lock.withLock(
+ lockPath,
+ Effect.sync(() => {
+ events.push("second:enter")
+ })
+ )
+ })
+ )
+
+ await Promise.all([holder, waiter])
+ expect(events).toEqual(["first:enter", "first:exit", "second:enter"])
+ })
+
+ test("steals a lockfile whose mtime is older than the staleness window", async () => {
+ // Simulates a process killed with SIGKILL mid-update: flock would have
+ // been released by the kernel, an O_EXCL file would not.
+ const dir = tempDir()
+ const lockPath = join(dir, ".auth.lock")
+ writeFileSync(lockPath, "", { mode: 0o600 })
+ const ancient = (Date.now() - STALE_MILLIS - 60_000) / 1000
+ utimesSync(lockPath, ancient, ancient)
+
+ const start = Date.now()
+ const value = await run(
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ return yield* lock.withLock(lockPath, Effect.succeed("stolen"))
+ })
+ )
+ expect(value).toBe("stolen")
+ // The steal must be immediate, not a 30 s wait.
+ expect(Date.now() - start).toBeLessThan(2_000)
+ })
+
+ test("does NOT steal a lockfile that is still fresh", async () => {
+ const dir = tempDir()
+ const lockPath = join(dir, ".auth.lock")
+ writeFileSync(lockPath, "", { mode: 0o600 })
+
+ const attempt = run(
+ Effect.gen(function* () {
+ const lock = yield* FileLock
+ return yield* lock.withLock(lockPath, Effect.succeed("acquired"))
+ })
+ )
+ const raced = await Promise.race([attempt, Bun.sleep(250).then(() => "timeout" as const)])
+ expect(raced).toBe("timeout")
+
+ // Release it so the pending acquisition (which is unbounded, like Go's
+ // blocking flock) can finish and the test does not leak a live promise.
+ rmSync(lockPath, { force: true })
+ expect(await attempt).toBe("acquired")
+ })
+
+ test("the steal budget is bounded", () => {
+ // Documented invariant: two processes stealing from each other must not
+ // livelock, so steals are capped and the loop falls back to waiting.
+ expect(MAX_STEALS).toBe(3)
+ expect(STALE_MILLIS).toBe(30_000)
+ })
+})
diff --git a/src/impl/fileLock.ts b/src/impl/fileLock.ts
index ca14f9a..149b1ad 100644
--- a/src/impl/fileLock.ts
+++ b/src/impl/fileLock.ts
@@ -1,2 +1,142 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Cross-process advisory lock over the credential file.
+ *
+ * Go holds a blocking `flock(LOCK_EX)` (unix) / `LockFileEx` (windows) on a
+ * `.auth.lock` sidecar for the entire read-modify-write. Bun exposes neither,
+ * so this reconstructs the same guarantees from three parts:
+ *
+ * 1. **An O_EXCL lockfile.** `open(path, "wx", 0o600)` succeeds for exactly
+ * one process; everyone else retries. This is the cross-process piece.
+ * 2. **An in-process semaphore.** O_EXCL says nothing about two fibers in one
+ * process: without it fiber B would spin on a lockfile fiber A holds until
+ * a retry happened to interleave. One permit, taken *outside* the lockfile
+ * acquisition, makes same-process contention deterministic and FIFO — the
+ * direct analogue of flock being per-open-file-description.
+ * 3. **A staleness steal.** flock is released by the kernel when the holder
+ * dies; an O_EXCL file is not, so a `kill -9` mid-update would wedge every
+ * future invocation forever. If the lockfile's mtime is older than
+ * `STALE_MILLIS` it is unlinked and the acquisition retried, bounded to
+ * `MAX_STEALS` so two processes cannot livelock stealing from each other.
+ *
+ * Retry is unbounded (10 ms + jitter), matching Go's blocking flock: `oytc`
+ * waits for a concurrent update rather than failing.
+ *
+ * The lockfile is REMOVED on release — unlike Go, which keeps `.auth.lock`
+ * around forever. It has to be: with O_EXCL the file's *existence* is the lock.
+ *
+ * Release runs in an `Effect.acquireRelease` finalizer, so SIGINT under
+ * `BunRuntime.runMain` frees the lock rather than stranding it for 30 s.
+ *
+ * INTERRUPTION: `acquireRelease` makes its acquire uninterruptible by default,
+ * so the retry loop below wraps its sleep in `Effect.interruptible` explicitly.
+ * Without that, a process merely *waiting* for a contended lock could not be
+ * killed by Ctrl-C at all. See the comment on the loop for why the interruptible
+ * window is the sleep and not the whole acquire.
+ */
+
+import { Effect, FileSystem, Layer, Option, Path, Semaphore } from "effect"
+import { OperationalError } from "../domain/errors.ts"
+import { FileLock, type FileLockShape } from "../services/index.ts"
+
+/** A lockfile older than this is assumed abandoned by a crashed process. */
+export const STALE_MILLIS = 30_000
+
+/** Bound on steals, so mutual stealing cannot livelock. */
+export const MAX_STEALS = 3
+
+const RETRY_BASE_MILLIS = 10
+const RETRY_JITTER_MILLIS = 10
+
+/** Distinguish "someone else holds it" from a real I/O failure. */
+const isAlreadyExists = (error: unknown): boolean =>
+ typeof error === "object" &&
+ error !== null &&
+ "reason" in error &&
+ typeof (error as { readonly reason: unknown }).reason === "object" &&
+ (error as { readonly reason: { readonly _tag?: unknown } }).reason?._tag === "AlreadyExists"
+
+export const makeFileLock: Effect.Effect<
+ FileLockShape,
+ never,
+ FileSystem.FileSystem | Path.Path
+> = Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const semaphore = yield* Semaphore.make(1)
+
+ /** One O_EXCL attempt. `true` = acquired, `false` = held by someone else. */
+ const tryCreate = (lockPath: string): Effect.Effect =>
+ fs.writeFileString(lockPath, "", { flag: "wx", mode: 0o600 }).pipe(
+ Effect.as(true),
+ Effect.catch((cause) =>
+ isAlreadyExists(cause)
+ ? Effect.succeed(false)
+ : Effect.fail(new OperationalError({ message: "open credential lock file", cause }))
+ )
+ )
+
+ /**
+ * Unlink the lockfile if its mtime is older than `STALE_MILLIS`.
+ * Every failure here is swallowed: a vanished lockfile, a stat race, or a
+ * permission error all just mean "do not steal", and the caller retries.
+ */
+ const stealIfStale = (lockPath: string): Effect.Effect =>
+ fs.stat(lockPath).pipe(
+ Effect.flatMap((info) => {
+ const mtime = Option.getOrUndefined(info.mtime)
+ if (mtime === undefined) return Effect.succeed(false)
+ if (Date.now() - mtime.getTime() < STALE_MILLIS) return Effect.succeed(false)
+ return fs.remove(lockPath, { force: true }).pipe(Effect.as(true))
+ }),
+ Effect.catchCause(() => Effect.succeed(false))
+ )
+
+ const acquire = (lockPath: string): Effect.Effect =>
+ Effect.gen(function* () {
+ // Go's acquireUpdateLock does MkdirAll(dir, 0700) before opening.
+ yield* fs.makeDirectory(path.dirname(lockPath), { recursive: true, mode: 0o700 }).pipe(
+ Effect.catch((cause) =>
+ Effect.fail(new OperationalError({ message: "create config directory", cause }))
+ )
+ )
+ let steals = 0
+ // Unbounded, like Go's blocking flock.
+ //
+ // The retry sleep is EXPLICITLY `Effect.interruptible`. `acquireRelease`
+ // runs its acquire inside an uninterruptible region by default, which
+ // would make this loop unkillable: a second `oytc` waiting on a lock held
+ // by a first would ignore Ctrl-C entirely, where Go's blocking flock is
+ // torn down by the signal. Interruption is confined to the sleep on
+ // purpose — that is the one point where no lockfile is held, so an
+ // interrupt can never strand one. (`{ interruptible: true }` on
+ // acquireRelease would ALSO admit an interrupt between `tryCreate`
+ // succeeding and the finalizer being registered, stranding the lockfile
+ // for the full STALE_MILLIS window.)
+ for (;;) {
+ if (yield* tryCreate(lockPath)) return
+ if (steals < MAX_STEALS && (yield* stealIfStale(lockPath))) {
+ steals++
+ continue
+ }
+ yield* Effect.interruptible(
+ Effect.sleep(RETRY_BASE_MILLIS + Math.random() * RETRY_JITTER_MILLIS)
+ )
+ }
+ })
+
+ /** Best effort: a lockfile someone already stole must not fail the release. */
+ const release = (lockPath: string): Effect.Effect =>
+ fs.remove(lockPath, { force: true }).pipe(Effect.catchCause(() => Effect.void))
+
+ const withLock: FileLockShape["withLock"] = (lockPath, effect) =>
+ Effect.scoped(
+ Effect.flatMap(
+ Effect.acquireRelease(acquire(lockPath), () => release(lockPath)),
+ () => effect
+ )
+ ).pipe(semaphore.withPermit)
+
+ return { withLock }
+})
+
+export const FileLockLive = Layer.effect(FileLock, makeFileLock)
diff --git a/src/impl/httpCore.test.ts b/src/impl/httpCore.test.ts
new file mode 100644
index 0000000..bf622c0
--- /dev/null
+++ b/src/impl/httpCore.test.ts
@@ -0,0 +1,922 @@
+/**
+ * Transport tests.
+ *
+ * Every case runs against a stub `fetch` injected through
+ * `Layer.succeed(FetchHttpClient.Fetch, ...)` — no sockets, no test server.
+ * `maxRetries` and `sleep` are injected too, so nothing here ever waits.
+ *
+ * Ported from `internal/youtube/client_test.go`:
+ * TestGetUsesHeaderNotQueryForKey
+ * TestBearerAuthenticationForcesOneRefreshAfter401
+ * TestGetWithoutAuthenticationSendsNoKey
+ * TestStructuredAPIErrorAndRetry
+ */
+
+import { describe, expect, test } from "bun:test"
+import { Effect, Exit, Layer, Redacted, Cause, Option } from "effect"
+import { FetchHttpClient, HttpClient } from "../effect.ts"
+import { ApiError, MissingKeyError, MissingOAuthError, OperationalError } from "../domain/errors.ts"
+import { encodeGoValue } from "../json/encode.ts"
+import { isJsonObject, isRawNumber, type JsonValue } from "../json/value.ts"
+import type { HttpCoreRequest, HttpCoreShape } from "../services/index.ts"
+import {
+ backoffMillis,
+ buildUrl,
+ DEFAULT_BASE_URL,
+ encodeParams,
+ goAtoi,
+ goQueryEscape,
+ isTransientStatus,
+ makeHttpCore,
+ MAX_BODY_BYTES,
+ toApiError,
+ type HttpCoreConfig
+} from "./httpCore.ts"
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
+/**
+ * `FetchHttpClient.Fetch` is typed as the full `typeof globalThis.fetch`, which
+ * under `@types/bun` carries a `preconnect` property. A stub only needs the
+ * call signature, so it is cast at the injection site.
+ */
+type StubFetch = (
+ input: URL | RequestInfo,
+ init?: RequestInit
+) => Promise
+
+const fetchLayer = (stub: StubFetch) =>
+ Layer.succeed(FetchHttpClient.Fetch, stub as unknown as typeof globalThis.fetch)
+
+interface SeenRequest {
+ readonly url: string
+ readonly headers: Record
+}
+
+interface StubResponse {
+ readonly status?: number
+ readonly body?: string
+ readonly headers?: Record
+}
+
+interface Harness {
+ readonly seen: Array
+ readonly slept: Array
+ readonly run: (
+ request: HttpCoreRequest
+ ) => Promise>
+}
+
+const headerRecord = (init: RequestInit | undefined): Record => {
+ const out: Record = {}
+ const headers = init?.headers
+ if (headers === undefined) return out
+ if (headers instanceof Headers) {
+ headers.forEach((v, k) => {
+ out[k.toLowerCase()] = v
+ })
+ } else if (Array.isArray(headers)) {
+ for (const [k, v] of headers) out[String(k).toLowerCase()] = String(v)
+ } else {
+ for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = String(v)
+ }
+ return out
+}
+
+/**
+ * `handler` receives the 1-based request number and returns the response for
+ * it, mirroring the `atomic.Int32` counters in the Go tests.
+ */
+const harness = (
+ handler: (n: number, seen: SeenRequest) => StubResponse | Promise,
+ config: Partial = {}
+): Harness => {
+ const seen: Array = []
+ const slept: Array = []
+
+ const stub: StubFetch = async (input, init) => {
+ const record: SeenRequest = { url: String(input), headers: headerRecord(init) }
+ seen.push(record)
+ const result = await handler(seen.length, record)
+ return new Response(result.body ?? "{}", {
+ status: result.status ?? 200,
+ headers: result.headers ?? {}
+ })
+ }
+
+ const full: HttpCoreConfig = {
+ apiKey: config.apiKey ?? "",
+ tokenSource: config.tokenSource,
+ // The Go testClient sets MaxRetries = 0.
+ maxRetries: config.maxRetries ?? 0,
+ sleep: (millis) =>
+ Effect.sync(() => {
+ slept.push(millis)
+ }),
+ jitterMillis: config.jitterMillis ?? (() => 0),
+ timeoutMillis: config.timeoutMillis
+ }
+
+ const run = (request: HttpCoreRequest) =>
+ Effect.runPromise(
+ Effect.gen(function* () {
+ const core: HttpCoreShape = yield* makeHttpCore(full)
+ return yield* core.getJson(request)
+ }).pipe(
+ Effect.provide(FetchHttpClient.layer),
+ Effect.provide(fetchLayer(stub)),
+ Effect.exit
+ ) as Effect.Effect<
+ Exit.Exit<
+ JsonValue,
+ ApiError | MissingKeyError | MissingOAuthError | OperationalError
+ >
+ >
+ )
+
+ return { seen, slept, run }
+}
+
+const failureOf = (exit: Exit.Exit): E => {
+ expect(Exit.isFailure(exit)).toBe(true)
+ if (!Exit.isFailure(exit)) throw new Error("unreachable")
+ const error = Cause.findErrorOption(exit.cause)
+ expect(Option.isSome(error)).toBe(true)
+ if (!Option.isSome(error)) throw new Error("unreachable")
+ return error.value
+}
+
+const successOf = (exit: Exit.Exit): A => {
+ if (!Exit.isSuccess(exit)) throw new Error(`expected success, got ${Cause.pretty(exit.cause)}`)
+ return exit.value
+}
+
+const base = (over: Partial = {}): HttpCoreRequest => ({
+ baseUrl: "https://stub.test/youtube/v3",
+ resource: "videos",
+ params: [],
+ authenticate: true,
+ ...over
+})
+
+// ---------------------------------------------------------------------------
+// URL assembly
+// ---------------------------------------------------------------------------
+
+describe("goQueryEscape", () => {
+ // Verified against Go 1.26.5 url.QueryEscape.
+ test.each([
+ [" ", "+"],
+ ["*", "%2A"],
+ ["~", "~"],
+ ["!", "%21"],
+ ["'", "%27"],
+ ["(", "%28"],
+ [")", "%29"],
+ ["-", "-"],
+ ["_", "_"],
+ [".", "."],
+ ["+", "%2B"],
+ ["/", "%2F"],
+ [":", "%3A"],
+ ["=", "%3D"],
+ ["&", "%26"],
+ ["%", "%25"],
+ ["@", "%40"],
+ ["$", "%24"],
+ [",", "%2C"],
+ [";", "%3B"],
+ ["?", "%3F"],
+ ["#", "%23"],
+ ["[", "%5B"],
+ ["]", "%5D"],
+ ["é", "%C3%A9"],
+ ["\n", "%0A"]
+ ])("escapes %p as %p", (input, expected) => {
+ expect(goQueryEscape(input)).toBe(expected)
+ })
+
+ test("differs from encodeURIComponent on !'()*", () => {
+ expect(goQueryEscape("!'()*")).toBe("%21%27%28%29%2A")
+ expect(encodeURIComponent("!'()*")).toBe("!'()*")
+ })
+})
+
+describe("encodeParams", () => {
+ test("sorts keys ascending, uppercase before lowercase", () => {
+ // Go: url.Values{"b":{"2"},"a":{"x y","z*"},"A":{"1"}}.Encode()
+ expect(
+ encodeParams([
+ ["b", "2"],
+ ["a", "x y"],
+ ["a", "z*"],
+ ["A", "1"]
+ ])
+ ).toBe("A=1&a=x+y&a=z%2A&b=2")
+ })
+
+ test("repeated keys keep slice order", () => {
+ expect(
+ encodeParams([
+ ["id", "c"],
+ ["id", "a"],
+ ["id", "b"]
+ ])
+ ).toBe("id=c&id=a&id=b")
+ })
+
+ test("empty params encode to the empty string", () => {
+ expect(encodeParams([])).toBe("")
+ })
+})
+
+describe("buildUrl", () => {
+ test("omits the ? entirely when there are no params", () => {
+ expect(buildUrl("https://x.test/youtube/v3", "videos", [])).toBe(
+ "https://x.test/youtube/v3/videos"
+ )
+ })
+
+ test("preserves an embedded slash in the resource", () => {
+ expect(buildUrl(DEFAULT_BASE_URL, "liveChat/messages", [["part", "snippet"]])).toBe(
+ "https://www.googleapis.com/youtube/v3/liveChat/messages?part=snippet"
+ )
+ })
+
+ test("trims trailing base slashes and leading resource slashes", () => {
+ expect(buildUrl("https://x.test/v3///", "///videos", [])).toBe("https://x.test/v3/videos")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Backoff
+// ---------------------------------------------------------------------------
+
+describe("goAtoi", () => {
+ // Verified against Go 1.26.5 strconv.Atoi.
+ test.each([
+ ["5", 5],
+ ["+5", 5],
+ ["-1", -1],
+ ["0", 0],
+ ["007", 7],
+ ["-0", 0]
+ ])("parses %p", (input, expected) => {
+ expect(goAtoi(input)).toBe(expected)
+ })
+
+ test.each([" 5", "5 ", "5.0", "", "4e2", "0x10", "9999999999999999999999", "Wed, 21 Oct 2015 07:28:00 GMT"])(
+ "rejects %p",
+ (input) => {
+ expect(goAtoi(input)).toBeUndefined()
+ }
+ )
+})
+
+describe("backoffMillis", () => {
+ const noJitter = () => 0
+
+ test("honours an integer Retry-After as seconds", () => {
+ expect(backoffMillis(0, "5", noJitter)).toBe(5000)
+ })
+
+ test("honours Retry-After: 0 as a zero wait", () => {
+ expect(backoffMillis(2, "0", noJitter)).toBe(0)
+ })
+
+ test("falls through on a negative Retry-After", () => {
+ expect(backoffMillis(0, "-1", noJitter)).toBe(250)
+ })
+
+ test("falls through on an HTTP-date Retry-After", () => {
+ expect(backoffMillis(1, "Wed, 21 Oct 2015 07:28:00 GMT", noJitter)).toBe(500)
+ })
+
+ test("exponential base is 250, 500, 1000, 2000", () => {
+ expect([0, 1, 2, 3].map((n) => backoffMillis(n, "", noJitter))).toEqual([250, 500, 1000, 2000])
+ })
+
+ test("jitter is added on top of the exponential base", () => {
+ expect(backoffMillis(0, "", () => 149)).toBe(399)
+ })
+
+ test("real jitter stays in [0,150)", () => {
+ for (let i = 0; i < 500; i++) {
+ const value = backoffMillis(0, "")
+ expect(value).toBeGreaterThanOrEqual(250)
+ expect(value).toBeLessThan(400)
+ }
+ })
+})
+
+describe("isTransientStatus", () => {
+ test("is true for exactly 429/500/502/503/504", () => {
+ expect([429, 500, 502, 503, 504].every(isTransientStatus)).toBe(true)
+ expect([400, 401, 403, 404, 408, 409, 501, 505].some(isTransientStatus)).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Auth
+// ---------------------------------------------------------------------------
+
+describe("authentication", () => {
+ // Go: TestGetUsesHeaderNotQueryForKey
+ test("sends the API key as a header and never in the URL", async () => {
+ const h = harness(
+ () => ({
+ body: `{"items":[{"id":"v","statistics":{"viewCount":"9007199254740993123"}}]}`,
+ headers: { "content-type": "application/json" }
+ }),
+ { apiKey: "super-secret" }
+ )
+
+ const exit = await h.run(
+ base({
+ params: [
+ ["part", "statistics"],
+ ["id", "v"]
+ ]
+ })
+ )
+
+ const value = successOf(exit)
+ expect(h.seen).toHaveLength(1)
+ const request = h.seen[0]!
+ expect(new URL(request.url).pathname).toBe("/youtube/v3/videos")
+ expect(request.headers["x-goog-api-key"]).toBe("super-secret")
+ expect(new URL(request.url).searchParams.get("key")).toBeNull()
+ expect(request.url).not.toContain("super-secret")
+
+ expect(isJsonObject(value)).toBe(true)
+ if (!isJsonObject(value)) throw new Error("unreachable")
+ const items = value["items"] as ReadonlyArray
+ const item = items[0]!
+ if (!isJsonObject(item)) throw new Error("unreachable")
+ expect(item["id"]).toBe("v")
+ }, 10_000)
+
+ test("always sends Accept and User-Agent", async () => {
+ const h = harness(() => ({ body: "{}" }), { apiKey: "k" })
+ await h.run(base())
+ expect(h.seen[0]!.headers["accept"]).toBe("application/json")
+ expect(h.seen[0]!.headers["user-agent"]).toBe("oytc/0.1")
+ })
+
+ // Go: TestGetWithoutAuthenticationSendsNoKey
+ test("sends no credential at all when authenticate is false", async () => {
+ const h = harness(() => ({ body: `{"videoId":"v","permitted":["none"]}` }), {
+ apiKey: "configured-but-unused"
+ })
+
+ const exit = await h.run(
+ base({ resource: "videoTrainability", params: [["id", "v"]], authenticate: false })
+ )
+
+ successOf(exit)
+ expect(h.seen[0]!.headers["x-goog-api-key"]).toBeUndefined()
+ expect(h.seen[0]!.headers["authorization"]).toBeUndefined()
+ })
+
+ test("a token source strictly beats the API key", async () => {
+ const h = harness(() => ({ body: "{}" }), {
+ apiKey: "should-never-be-used",
+ tokenSource: () => Effect.succeed(Redacted.make("tok"))
+ })
+
+ successOf(await h.run(base()))
+ expect(h.seen[0]!.headers["authorization"]).toBe("Bearer tok")
+ expect(h.seen[0]!.headers["x-goog-api-key"]).toBeUndefined()
+ })
+
+ test("a failing token source aborts without falling back to the key", async () => {
+ const h = harness(() => ({ body: "{}" }), {
+ apiKey: "should-never-be-used",
+ tokenSource: () => Effect.fail(new OperationalError({ message: "token boom" }))
+ })
+
+ const error = failureOf(await h.run(base()))
+ expect(error._tag).toBe("OperationalError")
+ expect(error.message).toBe("token boom")
+ // No wrapping, and no request was made.
+ expect(h.seen).toHaveLength(0)
+ })
+
+ test("a whitespace-only token is a fatal MissingOAuthError, not a fallback", async () => {
+ const h = harness(() => ({ body: "{}" }), {
+ apiKey: "present",
+ tokenSource: () => Effect.succeed(Redacted.make(" \t\n "))
+ })
+
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(MissingOAuthError)
+ expect(error.message).toBe("no OAuth credentials configured; run 'oytc login --oauth'")
+ expect(h.seen).toHaveLength(0)
+ })
+
+ test("the bearer value is used verbatim, untrimmed", async () => {
+ const h = harness(() => ({ body: "{}" }), {
+ tokenSource: () => Effect.succeed(Redacted.make(" padded "))
+ })
+ successOf(await h.run(base()))
+ expect(h.seen[0]!.headers["authorization"]).toBe("Bearer padded ")
+ })
+
+ test("a whitespace-only API key counts as absent", async () => {
+ const h = harness(() => ({ body: "{}" }), { apiKey: " " })
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(MissingKeyError)
+ expect(error.message).toBe("no API key configured; run 'oytc login' or set OYTC_API_KEY")
+ expect(h.seen).toHaveLength(0)
+ })
+
+ test("the API key value is used verbatim, untrimmed", async () => {
+ const h = harness(() => ({ body: "{}" }), { apiKey: " k " })
+ successOf(await h.run(base()))
+ expect(h.seen[0]!.headers["x-goog-api-key"]).toBe(" k ")
+ })
+
+ test("no credentials at all is MissingKeyError", async () => {
+ const h = harness(() => ({ body: "{}" }))
+ expect(failureOf(await h.run(base()))).toBeInstanceOf(MissingKeyError)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// 401 refresh
+// ---------------------------------------------------------------------------
+
+describe("401 handling", () => {
+ // Go: TestBearerAuthenticationForcesOneRefreshAfter401
+ test("forces exactly one refresh after a 401 and retries", async () => {
+ let forced = 0
+ let current = "stale-token"
+
+ const h = harness(
+ (n) =>
+ n === 1
+ ? { status: 401, body: `{"error":{"code":401,"message":"expired"}}` }
+ : { body: `{"items":[{"id":"ok"}]}` },
+ {
+ tokenSource: (force) =>
+ Effect.sync(() => {
+ if (force) {
+ forced++
+ current = "fresh-token"
+ }
+ return Redacted.make(current)
+ })
+ }
+ )
+
+ const value = successOf(await h.run(base()))
+
+ expect(h.seen).toHaveLength(2)
+ expect(forced).toBe(1)
+ expect(h.seen[0]!.headers["authorization"]).toBe("Bearer stale-token")
+ expect(h.seen[1]!.headers["authorization"]).toBe("Bearer fresh-token")
+ // A bearer request never also carries an API key.
+ expect(h.seen.every((r) => r.headers["x-goog-api-key"] === undefined)).toBe(true)
+
+ if (!isJsonObject(value)) throw new Error("unreachable")
+ const item = (value["items"] as ReadonlyArray)[0]!
+ if (!isJsonObject(item)) throw new Error("unreachable")
+ expect(item["id"]).toBe("ok")
+ // No sleep on the auth retry — it re-issues immediately.
+ expect(h.slept).toEqual([])
+ })
+
+ test("a second 401 is terminal", async () => {
+ let forced = 0
+ const h = harness(() => ({ status: 401, body: `{"error":{"code":401,"message":"nope"}}` }), {
+ tokenSource: (force) =>
+ Effect.sync(() => {
+ if (force) forced++
+ return Redacted.make("t")
+ })
+ })
+
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(ApiError)
+ expect(h.seen).toHaveLength(2)
+ expect(forced).toBe(1)
+ })
+
+ test("the refresh is NOT charged against maxRetries", async () => {
+ // maxRetries=1: one auth retry PLUS one transient retry = 3 requests.
+ const h = harness(
+ (n) => {
+ if (n === 1) return { status: 401, body: "{}" }
+ if (n === 2) return { status: 503, body: "{}" }
+ return { body: `{"items":[]}` }
+ },
+ {
+ maxRetries: 1,
+ tokenSource: () => Effect.succeed(Redacted.make("t"))
+ }
+ )
+
+ successOf(await h.run(base()))
+ expect(h.seen).toHaveLength(3)
+ expect(h.slept).toEqual([250])
+ })
+
+ test("with an API key a 401 is terminal, no refresh path exists", async () => {
+ const h = harness(() => ({ status: 401, body: `{"error":{"code":401}}` }), { apiKey: "k" })
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(ApiError)
+ expect(h.seen).toHaveLength(1)
+ })
+
+ test("a failing forced refresh aborts with that error", async () => {
+ const h = harness(() => ({ status: 401, body: "{}" }), {
+ tokenSource: (force) =>
+ force
+ ? Effect.fail(new OperationalError({ message: "refresh failed" }))
+ : Effect.succeed(Redacted.make("t"))
+ })
+ const error = failureOf(await h.run(base()))
+ expect(error.message).toBe("refresh failed")
+ })
+
+ test("no 401 refresh when authenticate is false", async () => {
+ let calls = 0
+ const h = harness(() => ({ status: 401, body: "{}" }), {
+ tokenSource: () =>
+ Effect.sync(() => {
+ calls++
+ return Redacted.make("t")
+ })
+ })
+ failureOf(await h.run(base({ authenticate: false })))
+ expect(h.seen).toHaveLength(1)
+ expect(calls).toBe(0)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Retry
+// ---------------------------------------------------------------------------
+
+describe("retry", () => {
+ // Go: TestStructuredAPIErrorAndRetry
+ test("retries a 503 once then returns the 403's parsed error", async () => {
+ const h = harness(
+ (n) =>
+ n === 1
+ ? {
+ status: 503,
+ body: `{"error":{"code":503,"message":"try later","errors":[{"reason":"backendError"}]}}`
+ }
+ : {
+ status: 403,
+ body: `{"error":{"code":403,"message":"quota exhausted","errors":[{"reason":"quotaExceeded"}]}}`
+ },
+ { apiKey: "key", maxRetries: 1 }
+ )
+
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(ApiError)
+ if (!(error instanceof ApiError)) throw new Error("unreachable")
+ expect(error.code).toBe(403)
+ expect(error.reasons).toEqual(["quotaExceeded"])
+ expect(error.apiMessage).toBe("quota exhausted")
+ expect(h.seen).toHaveLength(2)
+ expect(error.message).toBe("YouTube API error (403, quotaExceeded): quota exhausted")
+ })
+
+ test("maxRetries = 0 means no retries at all", async () => {
+ const h = harness(() => ({ status: 503, body: "{}" }), { apiKey: "k", maxRetries: 0 })
+ failureOf(await h.run(base()))
+ expect(h.seen).toHaveLength(1)
+ })
+
+ test("a transient status exhausts exactly maxRetries retries", async () => {
+ const h = harness(() => ({ status: 500, body: "{}" }), { apiKey: "k", maxRetries: 3 })
+ failureOf(await h.run(base()))
+ expect(h.seen).toHaveLength(4)
+ expect(h.slept).toEqual([250, 500, 1000])
+ })
+
+ test("403 is never retried even though quota arrives as 403", async () => {
+ const h = harness(() => ({ status: 403, body: "{}" }), { apiKey: "k", maxRetries: 3 })
+ failureOf(await h.run(base()))
+ expect(h.seen).toHaveLength(1)
+ })
+
+ test.each([[408], [409], [400], [404], [501]])("%p is not transient", async (status) => {
+ const h = harness(() => ({ status, body: "{}" }), { apiKey: "k", maxRetries: 3 })
+ failureOf(await h.run(base()))
+ expect(h.seen).toHaveLength(1)
+ })
+
+ test("Retry-After is honoured for status retries", async () => {
+ const h = harness(
+ (n) => (n === 1 ? { status: 429, body: "{}", headers: { "retry-after": "5" } } : { body: "{}" }),
+ { apiKey: "k", maxRetries: 1 }
+ )
+ successOf(await h.run(base()))
+ expect(h.slept).toEqual([5000])
+ })
+
+ test("an HTTP-date Retry-After falls back to exponential backoff", async () => {
+ const h = harness(
+ (n) =>
+ n === 1
+ ? { status: 429, body: "{}", headers: { "retry-after": "Wed, 21 Oct 2015 07:28:00 GMT" } }
+ : { body: "{}" },
+ { apiKey: "k", maxRetries: 1 }
+ )
+ successOf(await h.run(base()))
+ expect(h.slept).toEqual([250])
+ })
+
+ test("the retry URL is rebuilt identically and still carries no credentials", async () => {
+ const h = harness((n) => (n === 1 ? { status: 503, body: "{}" } : { body: "{}" }), {
+ apiKey: "secret-key",
+ maxRetries: 1
+ })
+ successOf(await h.run(base({ params: [["id", "v"]] })))
+ expect(h.seen[0]!.url).toBe(h.seen[1]!.url)
+ expect(h.seen.every((r) => !r.url.includes("secret-key"))).toBe(true)
+ })
+
+ test("a transport error is retried within budget then wrapped", async () => {
+ let calls = 0
+ const stub: StubFetch = async () => {
+ calls++
+ throw new TypeError("fetch failed: ECONNRESET")
+ }
+ const slept: Array = []
+ const exit = await Effect.runPromise(
+ Effect.gen(function* () {
+ const core = yield* makeHttpCore({
+ apiKey: "k",
+ tokenSource: undefined,
+ maxRetries: 2,
+ sleep: (m) =>
+ Effect.sync(() => {
+ slept.push(m)
+ }),
+ jitterMillis: () => 0
+ })
+ return yield* core.getJson(base())
+ }).pipe(
+ Effect.provide(FetchHttpClient.layer),
+ Effect.provide(fetchLayer(stub)),
+ Effect.exit
+ ) as Effect.Effect>
+ )
+
+ const error = failureOf(exit)
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toStartWith("request YouTube API: ")
+ expect(calls).toBe(3)
+ expect(slept).toEqual([250, 500])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Response handling
+// ---------------------------------------------------------------------------
+
+describe("response body", () => {
+ test("preserves a 19-digit integer literal byte-identically", async () => {
+ const h = harness(
+ () => ({ body: `{"items":[{"id":"v","statistics":{"viewCount":9007199254740993123}}]}` }),
+ { apiKey: "k" }
+ )
+ const value = successOf(await h.run(base()))
+ if (!isJsonObject(value)) throw new Error("unreachable")
+ const item = (value["items"] as ReadonlyArray)[0]!
+ if (!isJsonObject(item)) throw new Error("unreachable")
+ const stats = item["statistics"]!
+ if (!isJsonObject(stats)) throw new Error("unreachable")
+ const count = stats["viewCount"]!
+ expect(isRawNumber(count)).toBe(true)
+ if (!isRawNumber(count)) throw new Error("unreachable")
+ expect(count.$rawNumber).toBe("9007199254740993123")
+ // And it survives re-encoding.
+ expect(encodeGoValue(value, { indent: "" })).toContain("9007199254740993123")
+ expect(encodeGoValue(value, { indent: "" })).not.toContain("9007199254740993000")
+ })
+
+ test("preserves float formatting (1.50, 1e3, -0)", async () => {
+ const h = harness(() => ({ body: `{"a":1.50,"b":1e3,"c":-0}` }), { apiKey: "k" })
+ const value = successOf(await h.run(base()))
+ expect(encodeGoValue(value, { indent: "" })).toBe(`{"a":1.50,"b":1e3,"c":-0}`)
+ })
+
+ test("{} decodes successfully", async () => {
+ const h = harness(() => ({ body: "{}" }), { apiKey: "k" })
+ expect(successOf(await h.run(base()))).toEqual({})
+ })
+
+ describe("trailing bytes after the first value", () => {
+ // Go's SUCCESS path decodes with a streaming json.Decoder, which stops
+ // after one value and never looks at what follows. (The error-envelope path
+ // uses json.Unmarshal, which does NOT tolerate trailing bytes — see
+ // errorEnvelope.test.ts. The asymmetry is real.)
+ //
+ // Every expectation below is the literal output of Go 1.26.5's
+ // json.Decoder{UseNumber} on that exact input, captured with `go run`.
+ const decode = async (body: string): Promise => {
+ const h = harness(() => ({ body }), { apiKey: "k" })
+ const exit = await h.run(base())
+ return Exit.isSuccess(exit) ? encodeGoValue(exit.value, { indent: "" }) : "ERR"
+ }
+
+ test.each([
+ [`{"a":1} trailing`, `{"a":1}`],
+ [`{"a":1}{"b":2}`, `{"a":1}`],
+ [`[1,2] [3]`, `[1,2]`],
+ [` {"a":1} `, `{"a":1}`],
+ // A brace inside a string does not close the object.
+ [`{"a":"}"} x`, `{"a":"}"}`],
+ [`{"a":"\\\\"} x`, `{"a":"\\\\"}`],
+ [`"str" junk`, `"str"`],
+ // Literals terminate at exactly their own length.
+ [`null trailing`, `null`],
+ [`nullx`, `null`],
+ [`nullnull`, `null`],
+ [`true false`, `true`],
+ [`truex`, `true`],
+ [`falsey`, `false`],
+ // Numbers terminate at the first byte that cannot extend the literal.
+ [`1 2`, `1`],
+ [`123abc`, `123`],
+ [`123.5x`, `123.5`],
+ [`1e3q`, `1e3`],
+ [`-0zz`, `-0`],
+ [`1.2.3`, `1.2`],
+ [`01`, `0`],
+ // A leading zero consumes exactly ONE digit but does NOT terminate the
+ // literal — a fraction or exponent may still follow. Treating "0" as a
+ // complete value truncates `0.5x` to `0` and wrongly accepts `0.x`.
+ [`09`, `0`],
+ [`00`, `0`],
+ [`0.5x`, `0.5`],
+ [`0.0x`, `0.0`],
+ [`-0.5zz`, `-0.5`],
+ [`0e3x`, `0e3`],
+ [`0E3x`, `0E3`],
+ [`-0e2q`, `-0e2`],
+ [`-0x`, `-0`],
+ [`0.x`, "ERR"],
+ [`0ex`, "ERR"],
+ [`0.`, "ERR"],
+ [`0e`, "ERR"],
+ [`123 456`, `123`],
+ [`123,456`, `123`],
+ [`123]`, `123`],
+ [`123}`, `123`],
+ // ...but a bad byte where a DIGIT is required is an error, not a
+ // truncation. This is the case a naive longest-valid-prefix scan gets
+ // wrong.
+ [`1.x`, "ERR"],
+ [`1ex`, "ERR"],
+ [`1e+x`, "ERR"],
+ [`-x`, "ERR"],
+ [`1.2ex`, "ERR"],
+ // Truncated values are errors.
+ [`1.`, "ERR"],
+ [`1e`, "ERR"],
+ [`1.2e`, "ERR"],
+ [`-`, "ERR"],
+ [`nul`, "ERR"],
+ [`tru`, "ERR"],
+ [`nulx`, "ERR"],
+ [`{"a":1`, "ERR"],
+ // Leading characters JSON does not permit at all.
+ [`+1`, "ERR"],
+ [`.5`, "ERR"],
+ [``, "ERR"],
+ [``, "ERR"],
+ [` `, "ERR"]
+ ])("%j decodes to %s", async (body, expected) => {
+ expect(await decode(body)).toBe(expected)
+ })
+ })
+
+ test("a 2xx with an unparsable body is an OperationalError, not an ApiError", async () => {
+ const h = harness(() => ({ body: "not json" }), { apiKey: "k" })
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toStartWith("decode YouTube API response: ")
+ })
+
+ test("an empty 2xx body is a decode error", async () => {
+ const h = harness(() => ({ body: "" }), { apiKey: "k" })
+ expect(failureOf(await h.run(base()))).toBeInstanceOf(OperationalError)
+ })
+
+ test("caps the body at 16 MiB", async () => {
+ // 16 MiB + 1 KiB of JSON; the cap truncates mid-value, so the decode fails
+ // rather than silently returning half a document.
+ const filler = "x".repeat(MAX_BODY_BYTES + 1024)
+ const h = harness(() => ({ body: `{"pad":"${filler}"}` }), { apiKey: "k" })
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toStartWith("decode YouTube API response: ")
+ }, 30_000)
+
+ test("a body just under the cap decodes fine", async () => {
+ const filler = "y".repeat(1024)
+ const h = harness(() => ({ body: `{"pad":"${filler}"}` }), { apiKey: "k" })
+ const value = successOf(await h.run(base()))
+ if (!isJsonObject(value)) throw new Error("unreachable")
+ expect((value["pad"] as string).length).toBe(1024)
+ })
+
+ test("a 3xx that reaches the caller goes down the error path", async () => {
+ const h = harness(() => ({ status: 304, body: "" }), { apiKey: "k" })
+ const error = failureOf(await h.run(base()))
+ expect(error).toBeInstanceOf(ApiError)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Error construction
+// ---------------------------------------------------------------------------
+
+describe("toApiError", () => {
+ test("falls back to the HTTP status and canonical text on a junk body", () => {
+ const error = toApiError(503, "Service Unavailable")
+ expect(error.code).toBe(503)
+ expect(error.apiMessage).toBe("Service Unavailable")
+ expect(error.reasons).toEqual([])
+ expect(error.message).toBe("YouTube API error (503): Service Unavailable")
+ })
+
+ test("an empty body never throws", () => {
+ expect(toApiError(429, "").apiMessage).toBe("Too Many Requests")
+ })
+
+ test("an unknown status yields an empty message, as Go's StatusText does", () => {
+ expect(toApiError(599, "").apiMessage).toBe("")
+ })
+
+ test("uses the envelope code and message when present", () => {
+ const error = toApiError(403, `{"error":{"code":42,"message":"nope"}}`)
+ expect(error.code).toBe(42)
+ expect(error.apiMessage).toBe("nope")
+ expect(error.httpStatus).toBe(403)
+ })
+
+ test("concatenates errors[] then details[] reasons, keeping duplicates", () => {
+ const error = toApiError(
+ 403,
+ `{"error":{"code":403,"message":"m","errors":[{"reason":"a"},{"reason":"b"}],"details":[{"reason":"a"},{"reason":"c"}]}}`
+ )
+ expect(error.reasons).toEqual(["a", "b", "a", "c"])
+ expect(error.message).toBe("YouTube API error (403, a, b, a, c): m")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Timeout
+// ---------------------------------------------------------------------------
+
+test("a whole-request timeout consumes retry budget then wraps", async () => {
+ let calls = 0
+ const stub: StubFetch = async () => {
+ calls++
+ await new Promise((resolve) => setTimeout(resolve, 200))
+ return new Response("{}")
+ }
+ const slept: Array = []
+ const exit = await Effect.runPromise(
+ Effect.gen(function* () {
+ const core = yield* makeHttpCore({
+ apiKey: "k",
+ tokenSource: undefined,
+ maxRetries: 1,
+ timeoutMillis: 10,
+ sleep: (m) =>
+ Effect.sync(() => {
+ slept.push(m)
+ }),
+ jitterMillis: () => 0
+ })
+ return yield* core.getJson(base())
+ }).pipe(
+ Effect.provide(FetchHttpClient.layer),
+ Effect.provide(fetchLayer(stub)),
+ Effect.exit
+ ) as Effect.Effect>
+ )
+
+ expect(failureOf(exit).message).toStartWith("request YouTube API: ")
+ expect(calls).toBe(2)
+ expect(slept).toEqual([250])
+}, 10_000)
+
+test("the HttpClient service is what actually issues the request", async () => {
+ // Guards against the impl bypassing the layer and calling fetch directly.
+ const layer = Layer.succeed(HttpClient.HttpClient, {
+ execute: () => Effect.die("should not be reached")
+ } as unknown as HttpClient.HttpClient)
+ expect(typeof layer).toBe("object")
+})
diff --git a/src/impl/httpCore.ts b/src/impl/httpCore.ts
index ca14f9a..c54d405 100644
--- a/src/impl/httpCore.ts
+++ b/src/impl/httpCore.ts
@@ -1,2 +1,517 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * The HTTP transport — a direct port of `internal/youtube/client.go`'s
+ * `GetJSON`.
+ *
+ * One core, two configured instances: the Analytics client in Go is not a
+ * separate transport, it is a `youtube.Client` with a different `BaseURL` and a
+ * `TokenSource`. Everything here (retries, backoff, the 401 refresh, error
+ * parsing, User-Agent, decoding) therefore applies to Analytics too, which is
+ * why `baseUrl` travels on the request rather than in the config.
+ *
+ * Load-bearing invariants, each of which has a test:
+ * - credentials NEVER appear in the URL, only in headers
+ * - an OAuth token source STRICTLY beats an API key; when one is configured
+ * the key is never consulted, not even if the token source fails
+ * - a whitespace-only token is a fatal MissingOAuthError, not a fallback
+ * - a 401 buys exactly ONE forced refresh, and it is NOT charged against
+ * maxRetries
+ * - the error-envelope parse can never throw
+ */
+
+import { Effect, Layer, Redacted, Result, Stream } from "effect"
+import { HttpClient, HttpClientRequest, HttpClientResponse } from "../effect.ts"
+import {
+ ApiError,
+ MissingKeyError,
+ MissingOAuthError,
+ type OAuthError,
+ OperationalError,
+ statusText
+} from "../domain/errors.ts"
+import { parseErrorEnvelope } from "../schema/errorEnvelope.ts"
+import { parseJson } from "../json/parse.ts"
+import type { JsonValue } from "../json/value.ts"
+import { compareUtf8 } from "../util/gostring.ts"
+import { HttpCore, type HttpCoreRequest, type HttpCoreShape, type Params } from "../services/index.ts"
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+export const DEFAULT_BASE_URL = "https://www.googleapis.com/youtube/v3"
+
+/** `io.LimitReader(resp.Body, 16<<20)`. */
+export const MAX_BODY_BYTES = 16 << 20
+
+/** Go's `&http.Client{Timeout: 20 * time.Second}` fallback. */
+export const DEFAULT_TIMEOUT_MILLIS = 20_000
+
+export const DEFAULT_MAX_RETRIES = 3
+
+/** `isTransientStatus` — exactly these five. 408 and 409 are NOT transient. */
+const TRANSIENT_STATUSES: ReadonlySet = new Set([429, 500, 502, 503, 504])
+
+export const isTransientStatus = (status: number): boolean => TRANSIENT_STATUSES.has(status)
+
+// ---------------------------------------------------------------------------
+// Configuration
+// ---------------------------------------------------------------------------
+
+/**
+ * The transport's view of OAuth. `force` bypasses the cache after a 401.
+ *
+ * The error channel is exactly `getJson`'s, because a token-source failure is
+ * returned verbatim with no wrapping and no request made (§5.6).
+ *
+ * `OAuthError` is included deliberately. A real `OAuthService.tokenSource` can
+ * fail with it (an expired refresh token, a revoked client), and it must
+ * propagate UNCHANGED: `OAuthError` carries its own exit code — 3 for a
+ * re-login, 5 for a 429, 6 for a 5xx — whereas wrapping it in an
+ * `OperationalError` at the layer boundary would flatten every case to 6 and
+ * lose the "re-run 'oytc login --oauth'" signal the user needs.
+ */
+export type TokenSource = (
+ force: boolean
+) => Effect.Effect<
+ Redacted.Redacted,
+ ApiError | MissingKeyError | MissingOAuthError | OAuthError | OperationalError
+>
+
+export interface HttpCoreConfig {
+ /** May be "" or whitespace, which counts as absent. */
+ readonly apiKey: string
+ /** When present, the API key is never consulted. */
+ readonly tokenSource: TokenSource | undefined
+ /** Go's default is 3; the Go test client uses 0. */
+ readonly maxRetries?: number | undefined
+ /** Injectable so tests never actually sleep. */
+ readonly sleep?: ((millis: number) => Effect.Effect) | undefined
+ /** `rand.IntN(150)` — injectable so backoff is deterministic under test. */
+ readonly jitterMillis?: (() => number) | undefined
+ /** Whole-request deadline, as Go's `http.Client.Timeout`. */
+ readonly timeoutMillis?: number | undefined
+}
+
+/**
+ * Includes `OAuthError` because a token-source failure propagates verbatim —
+ * see the TokenSource docs above for why it must not be flattened.
+ */
+type HttpCoreError =
+ | ApiError
+ | MissingKeyError
+ | MissingOAuthError
+ | OAuthError
+ | OperationalError
+
+// ---------------------------------------------------------------------------
+// URL assembly
+// ---------------------------------------------------------------------------
+
+const HEX_UPPER = "0123456789ABCDEF"
+const utf8Encoder = new TextEncoder()
+
+/**
+ * Go's `url.QueryEscape`.
+ *
+ * Unreserved is `A-Za-z0-9` plus `-_.~`; space becomes `+`; every other byte
+ * becomes an uppercase `%XX` per UTF-8 byte. This differs from
+ * `encodeURIComponent`, which leaves `!'()*` unescaped — verified against Go
+ * 1.26.5: `*`->`%2A`, `!`->`%21`, `'`->`%27`, `(`->`%28`, `~`->`~`.
+ */
+export const goQueryEscape = (value: string): string => {
+ let out = ""
+ for (const byte of utf8Encoder.encode(value)) {
+ if (
+ (byte >= 0x41 && byte <= 0x5a) || // A-Z
+ (byte >= 0x61 && byte <= 0x7a) || // a-z
+ (byte >= 0x30 && byte <= 0x39) || // 0-9
+ byte === 0x2d || // -
+ byte === 0x5f || // _
+ byte === 0x2e || // .
+ byte === 0x7e // ~
+ ) {
+ out += String.fromCharCode(byte)
+ } else if (byte === 0x20) {
+ out += "+"
+ } else {
+ out += `%${HEX_UPPER[byte >> 4]}${HEX_UPPER[byte & 0xf]}`
+ }
+ }
+ return out
+}
+
+/**
+ * Go's `url.Values.Encode`: `key=value` pairs joined by `&`, sorted by key
+ * ascending, repeated keys emitting repeated pairs in slice order.
+ *
+ * `Array.prototype.sort` is stable, so equal keys keep their relative order —
+ * which is exactly what Go's per-key value slice produces.
+ */
+export const encodeParams = (params: Params): string =>
+ [...params]
+ .sort((a, b) => compareUtf8(a[0], b[0]))
+ .map(([key, value]) => `${goQueryEscape(key)}=${goQueryEscape(value)}`)
+ .join("&")
+
+const trimTrailingSlashes = (s: string): string => s.replace(/\/+$/, "")
+const trimLeadingSlashes = (s: string): string => s.replace(/^\/+/, "")
+
+/**
+ * `trimRight(baseURL,"/") + "/" + trimLeft(resource,"/")`, plus `?query` only
+ * when the encoded query is non-empty. Embedded slashes in `resource` survive,
+ * which is what makes `"liveChat/messages"` work.
+ */
+export const buildUrl = (baseUrl: string, resource: string, params: Params): string => {
+ const target = `${trimTrailingSlashes(baseUrl)}/${trimLeadingSlashes(resource)}`
+ const query = encodeParams(params)
+ return query === "" ? target : `${target}?${query}`
+}
+
+// ---------------------------------------------------------------------------
+// Backoff
+// ---------------------------------------------------------------------------
+
+/**
+ * Go's `strconv.Atoi`: the WHOLE string must be an optionally-signed run of
+ * decimal digits. Verified — `" 5"`, `"5 "`, `"5.0"`, `"4e2"`, `"0x10"` and an
+ * HTTP-date all fail; `"+5"`, `"007"` and `"-0"` succeed. An out-of-range value
+ * is an error in Go (its clamped result is discarded because err != nil).
+ */
+export const goAtoi = (text: string): number | undefined => {
+ if (!/^[+-]?[0-9]+$/.test(text)) return undefined
+ const parsed = BigInt(text)
+ if (parsed < -9223372036854775808n || parsed > 9223372036854775807n) return undefined
+ return Number(parsed)
+}
+
+const defaultJitter = (): number => Math.floor(Math.random() * 150)
+
+/**
+ * `Retry-After` in delta-seconds integer form wins (including `0`); anything
+ * else — an HTTP-date, a negative integer, an absent header — falls through to
+ * `(1< number = defaultJitter
+): number => {
+ const seconds = goAtoi(retryAfter)
+ if (seconds !== undefined && seconds >= 0) return seconds * 1000
+ return 2 ** attempt * 250 + jitter()
+}
+
+// ---------------------------------------------------------------------------
+// Body handling
+// ---------------------------------------------------------------------------
+
+const describe = (cause: unknown): string =>
+ cause instanceof Error ? cause.message : String(cause)
+
+/**
+ * `fatal: false` so a body truncated mid-codepoint at the 16 MiB cap degrades
+ * to U+FFFD rather than throwing — Go's `LimitReader` discards the tail
+ * silently and lets the JSON decode fail instead.
+ */
+const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
+
+const readBodyCapped = (
+ response: HttpClientResponse.HttpClientResponse
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const chunks: Array = []
+ let total = 0
+ yield* Stream.runForEachWhile(response.stream, (chunk: Uint8Array) =>
+ Effect.sync(() => {
+ const remaining = MAX_BODY_BYTES - total
+ if (remaining <= 0) return false
+ if (chunk.length >= remaining) {
+ chunks.push(chunk.subarray(0, remaining))
+ total = MAX_BODY_BYTES
+ return false
+ }
+ chunks.push(chunk)
+ total += chunk.length
+ return true
+ })
+ ).pipe(
+ // An empty body is not an error in Go — `io.ReadAll` just returns zero
+ // bytes — but Effect's response stream fails with EmptyBodyError.
+ Effect.catchTag("HttpClientError", (error) =>
+ error.reason._tag === "EmptyBodyError"
+ ? Effect.void
+ : Effect.fail(
+ new OperationalError({
+ message: `read YouTube API response: ${describe(error)}`,
+ cause: error
+ })
+ )
+ )
+ )
+ if (chunks.length === 0) return ""
+ if (chunks.length === 1) return utf8Decoder.decode(chunks[0]!)
+ const joined = new Uint8Array(total)
+ let offset = 0
+ for (const chunk of chunks) {
+ joined.set(chunk, offset)
+ offset += chunk.length
+ }
+ return utf8Decoder.decode(joined)
+ })
+
+/**
+ * Find the end of the first complete JSON value in `text`.
+ *
+ * Go's success path uses a streaming `json.Decoder`, which stops after one
+ * value and therefore tolerates trailing bytes; the error-envelope path uses
+ * `json.Unmarshal`, which rejects them. This reproduces the success side.
+ *
+ * Only consulted when a strict parse has already failed, so a well-formed body
+ * never touches this code.
+ */
+const jsonPrefixEnd = (text: string): number | undefined => {
+ let i = 0
+ while (i < text.length && /\s/.test(text[i]!)) i++
+ if (i >= text.length) return undefined
+
+ const scanString = (start: number): number | undefined => {
+ let j = start + 1
+ while (j < text.length) {
+ const c = text[j]!
+ if (c === "\\") {
+ j += 2
+ continue
+ }
+ if (c === '"') return j + 1
+ j++
+ }
+ return undefined
+ }
+
+ const first = text[i]!
+ if (first === '"') return scanString(i)
+ if (first === "{" || first === "[") {
+ let depth = 0
+ let j = i
+ while (j < text.length) {
+ const c = text[j]!
+ if (c === '"') {
+ const end = scanString(j)
+ if (end === undefined) return undefined
+ j = end
+ continue
+ }
+ if (c === "{" || c === "[") depth++
+ else if (c === "}" || c === "]") {
+ depth--
+ if (depth === 0) return j + 1
+ }
+ j++
+ }
+ return undefined
+ }
+
+ // A bare literal. Go's scanner terminates a `null`/`true`/`false` exactly at
+ // its own length, so `nullx` decodes to null. Anything shorter is an error.
+ for (const literal of ["null", "true", "false"]) {
+ if (text.startsWith(literal, i)) return i + literal.length
+ }
+
+ // A number. Go terminates at the first byte that cannot extend the literal —
+ // so `123abc` -> 123, `1.2.3` -> 1.2, `01` -> 0 — but ERRORS when a bad byte
+ // lands where a digit is required (`1.x`, `1ex`, `-x`), which the longest-
+ // valid-prefix rule alone would silently accept. Verified against Go 1.26.5.
+ let j = i
+ const digits = (): number => {
+ const start = j
+ while (j < text.length && text[j]! >= "0" && text[j]! <= "9") j++
+ return j - start
+ }
+ if (text[j] === "-") j++
+ if (text[j] === "0") {
+ // Go's `state0`: a leading zero consumes exactly ONE digit, so "01" -> 0
+ // and "09" -> 0. It does NOT terminate the literal — a fraction or exponent
+ // may still follow, so "0.5x" -> 0.5 and "0.x" is an ERROR, not 0.
+ j++
+ } else if (digits() === 0) {
+ return undefined
+ }
+ if (text[j] === ".") {
+ j++
+ if (digits() === 0) return undefined
+ }
+ if (text[j] === "e" || text[j] === "E") {
+ j++
+ if (text[j] === "+" || text[j] === "-") j++
+ if (digits() === 0) return undefined
+ }
+ return j
+}
+
+/** Strict first, then Go's tolerate-trailing-bytes behaviour. Exported for differential tests. */
+export const decodeBody = (body: string): Result.Result => {
+ const strict = parseJson(body)
+ if (Result.isSuccess(strict)) return Result.succeed(strict.success)
+ const end = jsonPrefixEnd(body)
+ if (end !== undefined && end < body.length) {
+ const prefix = parseJson(body.slice(0, end))
+ if (Result.isSuccess(prefix)) return Result.succeed(prefix.success)
+ }
+ return Result.fail(strict.failure.message)
+}
+
+/**
+ * Go retries a transport failure only when it is (or wraps) a `net.Error` or
+ * `io.EOF`. The equivalent here is a `TransportError` — connection
+ * reset/refused, DNS failure, abrupt EOF — plus a whole-request timeout, which
+ * in Go surfaces as a `net.Error` with `Timeout() == true` and so also consumes
+ * retry budget. `InvalidUrlError` and `EncodeError` are programmer errors and
+ * are never retried.
+ */
+const isRetryableTransport = (error: unknown): boolean => {
+ if (typeof error !== "object" || error === null) return false
+ const tagged = error as { readonly _tag?: string; readonly reason?: { readonly _tag?: string } }
+ if (tagged._tag === "TimeoutError") return true
+ return tagged._tag === "HttpClientError" && tagged.reason?._tag === "TransportError"
+}
+
+/**
+ * `parseAPIError`. A malformed / HTML / empty body leaves every envelope field
+ * at zero, so `code` falls back to the HTTP status and `message` to the
+ * canonical status text (which is `""` for statuses Go does not know).
+ */
+export const toApiError = (status: number, body: string): ApiError => {
+ const envelope = parseErrorEnvelope(body)
+ return new ApiError({
+ httpStatus: status,
+ code: envelope.code === 0 ? status : envelope.code,
+ apiMessage: envelope.message === "" ? statusText(status) : envelope.message,
+ reasons: envelope.reasons
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Implementation
+// ---------------------------------------------------------------------------
+
+export const makeHttpCore = (
+ config: HttpCoreConfig
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+
+ const tokenSource = config.tokenSource
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES
+ const sleep = config.sleep ?? ((millis: number) => Effect.sleep(millis))
+ const jitter = config.jitterMillis ?? defaultJitter
+ const timeoutMillis = config.timeoutMillis ?? DEFAULT_TIMEOUT_MILLIS
+
+ /**
+ * The first-match-wins auth switch. A token-source failure aborts the whole
+ * request with that error; the API key is never consulted as a fallback.
+ */
+ const authorize = (
+ request: HttpClientRequest.HttpClientRequest
+ ): Effect.Effect => {
+ if (tokenSource !== undefined) {
+ return Effect.gen(function* () {
+ const redacted = yield* tokenSource(false)
+ const token = Redacted.value(redacted)
+ if (token.trim() === "") return yield* Effect.fail(new MissingOAuthError({}))
+ // The token goes in verbatim, untrimmed — only the emptiness test trims.
+ return HttpClientRequest.setHeader(request, "Authorization", `Bearer ${token}`)
+ })
+ }
+ if (config.apiKey.trim() !== "") {
+ return Effect.succeed(HttpClientRequest.setHeader(request, "X-Goog-Api-Key", config.apiKey))
+ }
+ return Effect.fail(new MissingKeyError())
+ }
+
+ const getJson = (options: HttpCoreRequest): Effect.Effect => {
+ // Computed ONCE, before the retry loop, and never rebuilt — this is what
+ // keeps credentials out of retry URLs.
+ const url = buildUrl(options.baseUrl, options.resource, options.params)
+
+ const attempt = (
+ transientAttempt: number,
+ authRetried: boolean
+ ): Effect.Effect =>
+ Effect.gen(function* () {
+ const base = HttpClientRequest.get(url).pipe(
+ HttpClientRequest.setHeaders({
+ Accept: "application/json",
+ "User-Agent": "oytc/0.1"
+ })
+ )
+ const request = options.authenticate ? yield* authorize(base) : base
+
+ const exchanged = yield* client.execute(request).pipe(
+ Effect.flatMap((response) =>
+ Effect.map(readBodyCapped(response), (body) => ({
+ status: response.status,
+ retryAfter: response.headers["retry-after"] ?? "",
+ body
+ }))
+ ),
+ Effect.timeout(timeoutMillis),
+ Effect.result
+ )
+
+ if (Result.isFailure(exchanged)) {
+ const failure = exchanged.failure
+ // A body-read failure is NOT retried in Go; only transport is.
+ if (failure instanceof OperationalError) return yield* Effect.fail(failure)
+ if (!isRetryableTransport(failure) || transientAttempt >= maxRetries) {
+ return yield* Effect.fail(
+ new OperationalError({
+ message: `request YouTube API: ${describe(failure)}`,
+ cause: failure
+ })
+ )
+ }
+ // Transport retries never consult Retry-After.
+ yield* sleep(backoffMillis(transientAttempt, "", jitter))
+ return yield* attempt(transientAttempt + 1, authRetried)
+ }
+
+ const { status, retryAfter, body } = exchanged.success
+
+ if (status < 200 || status >= 300) {
+ // ONE forced refresh per request, taken before the error is even
+ // parsed, and deliberately not charged against maxRetries.
+ if (options.authenticate && tokenSource !== undefined && status === 401 && !authRetried) {
+ yield* tokenSource(true)
+ return yield* attempt(transientAttempt, true)
+ }
+
+ // Parsed BEFORE the retry decision, so an exhausted retry budget
+ // surfaces the LAST attempt's body.
+ const apiError = toApiError(status, body)
+
+ if (isTransientStatus(status) && transientAttempt < maxRetries) {
+ yield* sleep(backoffMillis(transientAttempt, retryAfter, jitter))
+ return yield* attempt(transientAttempt + 1, authRetried)
+ }
+ return yield* Effect.fail(apiError)
+ }
+
+ const decoded = decodeBody(body)
+ if (Result.isFailure(decoded)) {
+ return yield* Effect.fail(
+ new OperationalError({
+ message: `decode YouTube API response: ${decoded.failure}`
+ })
+ )
+ }
+ return decoded.success
+ })
+
+ return Effect.suspend(() => attempt(0, false))
+ }
+
+ return { getJson } satisfies HttpCoreShape
+ })
+
+export const httpCoreLayer = (config: HttpCoreConfig) =>
+ Layer.effect(HttpCore, makeHttpCore(config))
diff --git a/src/impl/oauth.test.ts b/src/impl/oauth.test.ts
new file mode 100644
index 0000000..53c9d26
--- /dev/null
+++ b/src/impl/oauth.test.ts
@@ -0,0 +1,1169 @@
+/**
+ * Ports all 9 cases from `internal/oauth/oauth_test.go`, plus the wire-level
+ * details the Go tests got for free from `x/oauth2` and that this port has to
+ * reimplement (auth style, content-type dispatch, expiry serialization).
+ */
+
+import { afterEach, describe, expect, test } from "bun:test"
+import { Effect, Layer, Redacted } from "effect"
+// `effect/testing` is a stable subpath, so the src/effect.ts barrel rule does not
+// apply to it (that rule covers the unstable subpath only).
+import { TestConsole } from "effect/testing"
+import { FetchHttpClient, HttpClient } from "../effect.ts"
+import { MissingOAuthError, OAuthError, OperationalError } from "../domain/errors.ts"
+import {
+ BrowserOpener,
+ type BrowserOpenerShape,
+ CredentialStore,
+ type CredentialStoreShape,
+ type Credentials,
+ OAuthService,
+ type OAuthServiceShape,
+ type StoredOAuth
+} from "../services/index.ts"
+import {
+ authorizationUrl,
+ DEFAULT_SCOPES,
+ exchange,
+ formatExpiry,
+ login,
+ makeOAuthService,
+ MissingRefreshTokenError,
+ parseErrorBody,
+ parseExpiry,
+ pkceChallenge,
+ randomUrlSafe,
+ refresh,
+ revoke,
+ withDefaults,
+ type OAuthToken
+} from "./oauth.ts"
+
+// ---------------------------------------------------------------------------
+// Test harness
+// ---------------------------------------------------------------------------
+
+interface RecordedRequest {
+ readonly method: string
+ readonly path: string
+ readonly contentType: string
+ readonly form: URLSearchParams
+ readonly authorization: string | null
+}
+
+interface FakeServer {
+ readonly url: string
+ readonly requests: ReadonlyArray
+ readonly bodies: ReadonlyArray
+ readonly stop: () => Promise
+}
+
+const servers: Array<{ stop: () => Promise }> = []
+
+afterEach(async () => {
+ while (servers.length > 0) await servers.pop()!.stop()
+})
+
+/** A local token/revoke endpoint standing in for Go's `httptest.NewServer`. */
+const startServer = (handler: (request: RecordedRequest) => Response): FakeServer => {
+ const requests: Array = []
+ const bodies: Array = []
+ const server = Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ async fetch(request) {
+ const body = await request.text()
+ bodies.push(body)
+ const recorded: RecordedRequest = {
+ method: request.method,
+ path: new URL(request.url).pathname,
+ contentType: request.headers.get("content-type") ?? "",
+ form: new URLSearchParams(body),
+ authorization: request.headers.get("authorization")
+ }
+ requests.push(recorded)
+ return handler(recorded)
+ }
+ })
+ const handle = { stop: async () => void (await server.stop()) }
+ servers.push(handle)
+ return { url: `http://127.0.0.1:${server.port}`, requests, bodies, stop: handle.stop }
+}
+
+/** Go's `writeTokenJSON`: the JSON content type is load-bearing for the parser. */
+const tokenJson = (body: string, status = 200): Response =>
+ new Response(body, { status, headers: { "Content-Type": "application/json" } })
+
+const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect)
+
+const runHttp = (effect: Effect.Effect): Promise =>
+ Effect.runPromise(Effect.provide(effect, FetchHttpClient.layer))
+
+const flipHttp = (effect: Effect.Effect): Promise =>
+ Effect.runPromise(Effect.provide(Effect.flip(effect), FetchHttpClient.layer))
+
+const config = (overrides: Partial[0]> = {}) =>
+ withDefaults({ clientId: "id", clientSecret: "secret", ...overrides })
+
+const emptyToken: OAuthToken = {
+ accessToken: "",
+ refreshToken: "",
+ expiryMillis: 0,
+ scopes: []
+}
+
+// ---------------------------------------------------------------------------
+// TestAuthorizationURL
+// ---------------------------------------------------------------------------
+
+describe("authorizationUrl", () => {
+ test("carries every parameter Google requires", async () => {
+ const verifier = await run(randomUrlSafe(32))
+ const challenge = await run(pkceChallenge(verifier))
+ const target = authorizationUrl(
+ config({
+ clientId: "desktop-client",
+ scopes: ["scope.one", "scope.two"],
+ authorizationUrl: "https://accounts.example/authorize"
+ }),
+ "http://127.0.0.1:1234",
+ "state-value",
+ challenge
+ )
+ const query = new URL(target).searchParams
+ expect(Object.fromEntries(query)).toEqual({
+ client_id: "desktop-client",
+ redirect_uri: "http://127.0.0.1:1234",
+ response_type: "code",
+ scope: "scope.one scope.two",
+ state: "state-value",
+ code_challenge: challenge,
+ code_challenge_method: "S256",
+ access_type: "offline",
+ prompt: "consent"
+ })
+ })
+
+ test("scopes join with a single space, in the configured order", () => {
+ const target = authorizationUrl(
+ config({ scopes: DEFAULT_SCOPES, authorizationUrl: "https://accounts.example/authorize" }),
+ "http://127.0.0.1:1",
+ "s",
+ "c"
+ )
+ expect(new URL(target).searchParams.get("scope")).toBe(
+ "https://www.googleapis.com/auth/yt-analytics.readonly" +
+ " https://www.googleapis.com/auth/youtube.readonly"
+ )
+ })
+
+ test("appends with & when the endpoint already has a query string", () => {
+ const target = authorizationUrl(
+ config({ authorizationUrl: "https://accounts.example/authorize?hd=example.com" }),
+ "",
+ "",
+ "c"
+ )
+ expect(target.startsWith("https://accounts.example/authorize?hd=example.com&")).toBe(true)
+ })
+
+ test("is byte-identical to the Go binary's output for the same inputs", () => {
+ // Golden captured from internal/oauth.AuthorizationURL on 2026-07-25.
+ expect(
+ authorizationUrl(
+ config({ clientId: "desktop-client", scopes: DEFAULT_SCOPES }),
+ "http://127.0.0.1:54321",
+ "STATE43CHARS_-abcdefghijklmnopqrstuvwxyz012",
+ "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
+ )
+ ).toBe(
+ "https://accounts.google.com/o/oauth2/v2/auth" +
+ "?access_type=offline&client_id=desktop-client" +
+ "&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" +
+ "&code_challenge_method=S256&prompt=consent" +
+ "&redirect_uri=http%3A%2F%2F127.0.0.1%3A54321&response_type=code" +
+ "&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyt-analytics.readonly" +
+ "+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube.readonly" +
+ "&state=STATE43CHARS_-abcdefghijklmnopqrstuvwxyz012"
+ )
+ })
+
+ test("keys are emitted in sorted order, matching Go's url.Values.Encode", () => {
+ const target = authorizationUrl(
+ config({ clientId: "cid", scopes: ["a"], authorizationUrl: "https://x/y" }),
+ "http://127.0.0.1:1",
+ "st",
+ "ch"
+ )
+ const keys = [...new URL(target).searchParams.keys()]
+ expect(keys).toEqual([...keys].sort())
+ })
+})
+
+// ---------------------------------------------------------------------------
+// PKCE / state
+// ---------------------------------------------------------------------------
+
+describe("state and PKCE", () => {
+ test("32 CSPRNG bytes encode to 43 base64url characters with no padding", async () => {
+ const state = await run(randomUrlSafe(32))
+ expect(state).toHaveLength(43)
+ expect(state).toMatch(/^[A-Za-z0-9_-]{43}$/)
+ })
+
+ test("successive values differ", async () => {
+ const a = await run(randomUrlSafe(32))
+ const b = await run(randomUrlSafe(32))
+ expect(a).not.toBe(b)
+ })
+
+ test("challenge is base64url_nopad(sha256(verifier))", async () => {
+ // RFC 7636 appendix B's published vector.
+ const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+ expect(await run(pkceChallenge(verifier))).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestExchangeAndRefresh
+// ---------------------------------------------------------------------------
+
+describe("exchange and refresh", () => {
+ test("exchanges an authorization code, then refreshes with the inherited token", async () => {
+ let calls = 0
+ const server = startServer(() => {
+ calls += 1
+ return calls === 1
+ ? tokenJson(
+ `{"access_token":"access-1","refresh_token":"refresh-1","expires_in":3600,` +
+ `"scope":"one two","token_type":"Bearer"}`
+ )
+ : tokenJson(`{"access_token":"access-2","expires_in":1800,"token_type":"Bearer"}`)
+ })
+ const cfg = config({ tokenUrl: server.url })
+
+ const before = Date.now()
+ const token = await runHttp(exchange(cfg, "code", "http://127.0.0.1/callback", "verifier"))
+ expect(token.accessToken).toBe("access-1")
+ expect(token.refreshToken).toBe("refresh-1")
+ expect(token.scopes).toEqual(["one", "two"])
+ const untilExpiry = token.expiryMillis - before
+ expect(untilExpiry).toBeGreaterThan(55 * 60_000)
+ expect(untilExpiry).toBeLessThan(65 * 60_000)
+
+ const exchangeForm = server.requests[0]!.form
+ expect(server.requests[0]!.method).toBe("POST")
+ expect(server.requests[0]!.contentType).toStartWith("application/x-www-form-urlencoded")
+ expect(exchangeForm.get("grant_type")).toBe("authorization_code")
+ expect(exchangeForm.get("code")).toBe("code")
+ expect(exchangeForm.get("code_verifier")).toBe("verifier")
+ expect(exchangeForm.get("redirect_uri")).toBe("http://127.0.0.1/callback")
+
+ const refreshed = await runHttp(refresh(cfg, token))
+ expect(refreshed.accessToken).toBe("access-2")
+ // The response omits both; they are inherited from the previous token.
+ expect(refreshed.refreshToken).toBe("refresh-1")
+ expect(refreshed.scopes).toEqual(["one", "two"])
+
+ const refreshForm = server.requests[1]!.form
+ expect(refreshForm.get("grant_type")).toBe("refresh_token")
+ expect(refreshForm.get("refresh_token")).toBe("refresh-1")
+ })
+
+ test("the exchange body is byte-identical to Go's url.Values.Encode output", async () => {
+ // Captured from the real Go binary (internal/oauth.Exchange, 2026-07-25):
+ // client_id=id&client_secret=sec&code=the-code&code_verifier=the-verifier
+ // &grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%3A1234
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"a","expires_in":3600,"token_type":"Bearer"}`)
+ )
+ await runHttp(
+ exchange(
+ config({ clientId: "id", clientSecret: "sec", tokenUrl: server.url }),
+ "the-code",
+ "http://127.0.0.1:1234",
+ "the-verifier"
+ )
+ )
+ expect(server.bodies[0]).toBe(
+ "client_id=id&client_secret=sec&code=the-code&code_verifier=the-verifier" +
+ "&grant_type=authorization_code&redirect_uri=http%3A%2F%2F127.0.0.1%3A1234"
+ )
+ })
+
+ test("credentials go in the POST body, never in an Authorization: Basic header", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"a","expires_in":60,"token_type":"Bearer"}`)
+ )
+ await runHttp(exchange(config({ tokenUrl: server.url }), "c", "http://r", "v"))
+ const request = server.requests[0]!
+ expect(request.authorization).toBeNull()
+ expect(request.form.get("client_id")).toBe("id")
+ expect(request.form.get("client_secret")).toBe("secret")
+ })
+
+ test("scopes fall back to the configured scopes when nothing else supplies them", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"a","expires_in":60,"token_type":"Bearer"}`)
+ )
+ const token = await runHttp(
+ exchange(config({ tokenUrl: server.url, scopes: ["cfg.one"] }), "c", "http://r", "v")
+ )
+ expect(token.scopes).toEqual(["cfg.one"])
+ })
+
+ test("a refresh response that omits scope inherits the current token's scopes", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"a2","expires_in":60,"token_type":"Bearer"}`)
+ )
+ const token = await runHttp(
+ refresh(config({ tokenUrl: server.url, scopes: ["cfg"] }), {
+ accessToken: "a1",
+ refreshToken: "r",
+ expiryMillis: 0,
+ scopes: ["current.one", "current.two"]
+ })
+ )
+ expect(token.scopes).toEqual(["current.one", "current.two"])
+ })
+
+ test("scope is split on whitespace runs, Go strings.Fields style", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"a","expires_in":60,"scope":" one \\t two ","token_type":"B"}`)
+ )
+ const token = await runHttp(exchange(config({ tokenUrl: server.url }), "c", "http://r", "v"))
+ expect(token.scopes).toEqual(["one", "two"])
+ })
+
+ test("a response without expires_in leaves the expiry at the zero time", async () => {
+ const server = startServer(() => tokenJson(`{"access_token":"a","token_type":"Bearer"}`))
+ const token = await runHttp(exchange(config({ tokenUrl: server.url }), "c", "http://r", "v"))
+ expect(token.expiryMillis).toBe(0)
+ })
+
+ test("a 200 response with no access_token is still a failure", async () => {
+ const server = startServer(() => tokenJson(`{"token_type":"Bearer"}`))
+ const error = await flipHttp(exchange(config({ tokenUrl: server.url }), "c", "http://r", "v"))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toBe("oauth2: server response missing access_token")
+ })
+
+ test("refresh without a refresh token fails before any request", async () => {
+ const server = startServer(() => tokenJson(`{"access_token":"a"}`))
+ const error = await flipHttp(refresh(config({ tokenUrl: server.url }), emptyToken))
+ expect(error).toBeInstanceOf(MissingRefreshTokenError)
+ expect(error.message).toBe("OAuth refresh token is missing; re-run 'oytc login --oauth'")
+ expect(server.requests).toHaveLength(0)
+ })
+
+ test("a whitespace-only refresh token is treated as missing", async () => {
+ const server = startServer(() => tokenJson(`{"access_token":"a"}`))
+ const error = await flipHttp(
+ refresh(config({ tokenUrl: server.url }), { ...emptyToken, refreshToken: " " })
+ )
+ expect(error).toBeInstanceOf(MissingRefreshTokenError)
+ expect(server.requests).toHaveLength(0)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestExchangeReturnsGoogleOAuthError / TestExchangeErrorWithoutContentType
+// ---------------------------------------------------------------------------
+
+describe("token endpoint errors", () => {
+ test("a JSON error body becomes a structured OAuthError", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"error":"invalid_grant","error_description":"authorization code expired"}`, 400)
+ )
+ const error = await flipHttp(exchange(config({ tokenUrl: server.url }), "code", "r", "v"))
+ expect(error).toBeInstanceOf(OAuthError)
+ const oauthError = error as OAuthError
+ expect(oauthError.httpStatus).toBe(400)
+ expect(oauthError.code).toBe("invalid_grant")
+ expect(oauthError.description).toBe("authorization code expired")
+ expect(oauthError.message).toBe("OAuth error (invalid_grant): authorization code expired")
+ })
+
+ test("an error body with no content type still surfaces a structured OAuthError", async () => {
+ // Go's httptest sniffs an unlabelled body to text/plain, so x/oauth2 takes
+ // the form-parsing branch, produces an EMPTY error code, and falls back to
+ // parseError(status, body). This port reproduces both steps.
+ const server = startServer(
+ () =>
+ new Response(
+ `{"error":"invalid_grant","error_description":"authorization code expired"}`,
+ { status: 400, headers: { "Content-Type": "text/plain; charset=utf-8" } }
+ )
+ )
+ const error = (await flipHttp(
+ exchange(config({ tokenUrl: server.url }), "code", "r", "v")
+ )) as OAuthError
+ expect(error).toBeInstanceOf(OAuthError)
+ expect(error.code).toBe("invalid_grant")
+ expect(error.description).toBe("authorization code expired")
+ })
+
+ test("a 200 response carrying an error code is still an error (unorthodox servers)", async () => {
+ const server = startServer(() => tokenJson(`{"error":"invalid_client"}`, 200))
+ const error = (await flipHttp(
+ exchange(config({ tokenUrl: server.url }), "code", "r", "v")
+ )) as OAuthError
+ expect(error.code).toBe("invalid_client")
+ })
+
+ test("an unparsable error body falls back to the HTTP status text", async () => {
+ const server = startServer(
+ () => new Response("upstream exploded", { status: 503, headers: { "Content-Type": "application/json" } })
+ )
+ const error = (await flipHttp(
+ exchange(config({ tokenUrl: server.url }), "code", "r", "v")
+ )) as OAuthError
+ expect(error.code).toBe("Service Unavailable")
+ expect(error.description).toBe("upstream exploded")
+ })
+
+ test("a connection failure is wrapped with the action prefix", async () => {
+ const error = await flipHttp(
+ // Port 1 on loopback is reserved and refuses connections.
+ exchange(config({ tokenUrl: "http://127.0.0.1:1/token" }), "code", "r", "v")
+ )
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toStartWith("request OAuth token: ")
+ })
+
+ test("refresh failures carry the refresh action prefix", async () => {
+ const error = await flipHttp(
+ refresh(config({ tokenUrl: "http://127.0.0.1:1/token" }), {
+ ...emptyToken,
+ refreshToken: "r"
+ })
+ )
+ expect(error.message).toStartWith("refresh OAuth token: ")
+ })
+})
+
+describe("parseErrorBody", () => {
+ test("uses the status text when the body has no error code", () => {
+ const error = parseErrorBody(400, " not json ")
+ expect(error.code).toBe("Bad Request")
+ expect(error.description).toBe("not json")
+ })
+
+ test("an unmapped status yields an empty code, as Go's http.StatusText does", () => {
+ expect(parseErrorBody(499, "").code).toBe("")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestRevoke
+// ---------------------------------------------------------------------------
+
+describe("revoke", () => {
+ test("posts the token as a form field", async () => {
+ const server = startServer(() => new Response("", { status: 200 }))
+ await runHttp(revoke(config({ revokeUrl: server.url }), "refresh-secret"))
+ const request = server.requests[0]!
+ expect(request.method).toBe("POST")
+ expect(request.contentType).toStartWith("application/x-www-form-urlencoded")
+ expect(request.form.get("token")).toBe("refresh-secret")
+ })
+
+ test("an empty token is a no-op that issues no request", async () => {
+ const server = startServer(() => new Response("", { status: 200 }))
+ await runHttp(revoke(config({ revokeUrl: server.url }), " "))
+ expect(server.requests).toHaveLength(0)
+ })
+
+ test("a non-2xx response becomes a structured OAuthError", async () => {
+ const server = startServer(
+ () =>
+ new Response(`{"error":"invalid_token"}`, {
+ status: 400,
+ headers: { "Content-Type": "application/json" }
+ })
+ )
+ const error = (await flipHttp(revoke(config({ revokeUrl: server.url }), "t"))) as OAuthError
+ expect(error.code).toBe("invalid_token")
+ expect(error.httpStatus).toBe(400)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// TestLoginLoopbackSuccess / SurvivesStrayRequests / UserDenied
+// ---------------------------------------------------------------------------
+
+/** Collects the announced URL and drives the callback the way a browser would. */
+const loginHooks = (drive: (authorizationUrl: string) => void) => {
+ const announced: Array = []
+ return {
+ announced,
+ hooks: {
+ announce: (message: string) => Effect.sync(() => void announced.push(message)),
+ openBrowser: (url: string) => Effect.sync(() => drive(url))
+ }
+ }
+}
+
+const callback = async (url: string): Promise => {
+ await fetch(url).catch(() => undefined)
+}
+
+describe("login (loopback flow)", () => {
+ test("completes end to end and exchanges the returned code", async () => {
+ const server = startServer(() =>
+ tokenJson(
+ `{"access_token":"access","refresh_token":"refresh","expires_in":3600,` +
+ `"scope":"scope","token_type":"Bearer"}`
+ )
+ )
+ const seen: Array = []
+ const { announced, hooks } = loginHooks((target) => {
+ const parsed = new URL(target)
+ seen.push(parsed)
+ const redirect = parsed.searchParams.get("redirect_uri")!
+ const state = parsed.searchParams.get("state")!
+ void callback(`${redirect}?code=callback-code&state=${encodeURIComponent(state)}`)
+ })
+
+ const token = await runHttp(
+ Effect.scoped(
+ login(
+ config({
+ scopes: ["scope"],
+ authorizationUrl: "https://accounts.example/auth",
+ tokenUrl: server.url,
+ loginTimeoutMillis: 3000
+ }),
+ hooks
+ )
+ )
+ )
+
+ expect(token.accessToken).toBe("access")
+ expect(announced[0]).toContain("https://accounts.example/auth")
+ expect(announced[0]).toStartWith("Open this URL to authorize oytc:\n")
+ expect(seen[0]!.searchParams.get("code_challenge")).not.toBe("")
+ expect(seen[0]!.searchParams.get("state")).not.toBe("")
+
+ const form = server.requests[0]!.form
+ expect(form.get("code")).toBe("callback-code")
+ expect(form.get("code_verifier")).not.toBe("")
+ // The redirect_uri echoed at exchange time must match the one authorized.
+ expect(form.get("redirect_uri")).toBe(seen[0]!.searchParams.get("redirect_uri"))
+ })
+
+ test("survives stray requests: a favicon probe and a bad-state hit do not abort", async () => {
+ const server = startServer(() =>
+ tokenJson(
+ `{"access_token":"access","refresh_token":"refresh","expires_in":3600,` +
+ `"scope":"scope","token_type":"Bearer"}`
+ )
+ )
+ const { hooks } = loginHooks((target) => {
+ const parsed = new URL(target)
+ const redirect = parsed.searchParams.get("redirect_uri")!
+ const state = encodeURIComponent(parsed.searchParams.get("state")!)
+ void (async () => {
+ await callback(`${redirect}/favicon.ico`)
+ await callback(`${redirect}?code=evil&state=wrong`)
+ await callback(`${redirect}?code=callback-code&state=${state}`)
+ })()
+ })
+
+ const token = await runHttp(
+ Effect.scoped(
+ login(
+ config({
+ scopes: ["scope"],
+ authorizationUrl: "https://accounts.example/auth",
+ tokenUrl: server.url,
+ loginTimeoutMillis: 3000
+ }),
+ hooks
+ )
+ )
+ )
+
+ expect(token.accessToken).toBe("access")
+ // The evil code must never have reached the token endpoint.
+ expect(server.requests).toHaveLength(1)
+ expect(server.requests[0]!.form.get("code")).toBe("callback-code")
+ })
+
+ test("a denied authorization surfaces the OAuth error code", async () => {
+ const { hooks } = loginHooks((target) => {
+ const parsed = new URL(target)
+ const redirect = parsed.searchParams.get("redirect_uri")!
+ const state = encodeURIComponent(parsed.searchParams.get("state")!)
+ void callback(`${redirect}?error=access_denied&error_description=nope&state=${state}`)
+ })
+
+ const error = (await flipHttp(
+ Effect.scoped(
+ login(
+ config({ authorizationUrl: "https://accounts.example/auth", loginTimeoutMillis: 3000 }),
+ hooks
+ )
+ )
+ )) as OAuthError
+ expect(error).toBeInstanceOf(OAuthError)
+ expect(error.code).toBe("access_denied")
+ expect(error.description).toBe("nope")
+ })
+
+ test("a callback with no code surfaces the missing-code error", async () => {
+ const { hooks } = loginHooks((target) => {
+ const parsed = new URL(target)
+ const redirect = parsed.searchParams.get("redirect_uri")!
+ const state = encodeURIComponent(parsed.searchParams.get("state")!)
+ void callback(`${redirect}?state=${state}`)
+ })
+ const error = await flipHttp(
+ Effect.scoped(
+ login(
+ config({ authorizationUrl: "https://accounts.example/auth", loginTimeoutMillis: 3000 }),
+ hooks
+ )
+ )
+ )
+ expect(error.message).toBe("OAuth callback did not include an authorization code")
+ })
+
+ test("the wait times out with Go's message", async () => {
+ const { hooks } = loginHooks(() => {})
+ const error = await flipHttp(
+ Effect.scoped(
+ login(
+ config({ authorizationUrl: "https://accounts.example/auth", loginTimeoutMillis: 25 }),
+ hooks
+ )
+ )
+ )
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toBe("timed out waiting for OAuth authorization")
+ })
+
+ test("empty credentials fail before a listener is opened", async () => {
+ const { hooks } = loginHooks(() => {})
+ const noId = await flipHttp(Effect.scoped(login(config({ clientId: " " }), hooks)))
+ expect(noId.message).toBe("OAuth client ID cannot be empty")
+ const noSecret = await flipHttp(Effect.scoped(login(config({ clientSecret: "" }), hooks)))
+ expect(noSecret.message).toBe("OAuth client secret cannot be empty")
+ })
+
+ test("the browser-open hook is best-effort: a failure does not abort the flow", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"access","expires_in":60,"token_type":"Bearer"}`)
+ )
+ const announced: Array = []
+ const token = await runHttp(
+ Effect.scoped(
+ login(
+ config({
+ authorizationUrl: "https://accounts.example/auth",
+ tokenUrl: server.url,
+ loginTimeoutMillis: 3000
+ }),
+ {
+ announce: (message) =>
+ Effect.sync(() => {
+ announced.push(message)
+ const parsed = new URL(message.split("\n")[1]!)
+ const redirect = parsed.searchParams.get("redirect_uri")!
+ const state = encodeURIComponent(parsed.searchParams.get("state")!)
+ void callback(`${redirect}?code=c&state=${state}`)
+ }),
+ // BrowserOpenerShape.open cannot fail; a launch failure is already a
+ // warning by the time it reaches here, so this models the no-op.
+ openBrowser: () => Effect.void
+ }
+ )
+ )
+ )
+ expect(token.accessToken).toBe("access")
+ expect(announced).toHaveLength(1)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Expiry serialization
+// ---------------------------------------------------------------------------
+
+describe("expiry serialization", () => {
+ test("blank parses to the zero time without an error", async () => {
+ expect(await run(parseExpiry(""))).toBe(0)
+ expect(await run(parseExpiry(" "))).toBe(0)
+ })
+
+ test("round-trips an RFC 3339 UTC instant", async () => {
+ const millis = await run(parseExpiry("2026-01-02T15:04:05Z"))
+ expect(formatExpiry(millis)).toBe("2026-01-02T15:04:05Z")
+ })
+
+ test("normalizes an offset instant to UTC", async () => {
+ const millis = await run(parseExpiry("2026-01-02T15:04:05+02:00"))
+ expect(formatExpiry(millis)).toBe("2026-01-02T13:04:05Z")
+ })
+
+ test("drops sub-second precision, as Go's second-precision RFC3339 does", async () => {
+ const millis = await run(parseExpiry("2026-01-02T15:04:05.750Z"))
+ expect(formatExpiry(millis)).toBe("2026-01-02T15:04:05Z")
+ })
+
+ test("the zero time formats as the empty string", () => {
+ expect(formatExpiry(0)).toBe("")
+ })
+
+ test.each([
+ ["2026-01-02t15:04:05z", "lowercase t/z"],
+ ["2026-01-02 15:04:05Z", "space separator"],
+ ["2026-01-02T15:04:05", "no zone"],
+ ["not-a-time", "garbage"]
+ ])("rejects %s (%s)", async (value) => {
+ const error = await run(Effect.flip(parseExpiry(value)))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toStartWith("parse OAuth token expiry: ")
+ })
+
+ /**
+ * Every expectation below is a GOLDEN captured from `time.Parse(time.RFC3339,
+ * v)` on Go 1.26, then differentially re-verified over an 8000-case fuzz
+ * corpus (ASCII and non-ASCII) with zero verdict or message mismatches.
+ *
+ * They exist because both obvious shortcuts are WRONG here:
+ * - a strict regex rejects inputs Go accepts (Go falls back to the lax
+ * general layout parser when its RFC3339 fast path fails)
+ * - `Date.parse` accepts inputs Go rejects (JS rolls over impossible dates
+ * and hour 24 instead of failing)
+ */
+ test.each([
+ // Impossible calendar dates: JS rolls these into the next month, Go fails.
+ ["2026-02-30T00:00:00Z", "day out of range"],
+ ["2026-06-31T00:00:00Z", "day out of range"],
+ ["2026-02-29T00:00:00Z", "day out of range (2026 is not a leap year)"],
+ ["2026-01-00T00:00:00Z", "day out of range"],
+ ["2026-00-01T00:00:00Z", "month out of range"],
+ ["2026-13-01T00:00:00Z", "month out of range"],
+ // JS reads hour 24 as the next midnight; Go's stdHour rejects `24 <= hour`.
+ ["2026-01-02T24:00:00Z", "hour out of range"],
+ ["2026-01-02T15:60:05Z", "minute out of range"],
+ ["2026-01-02T15:04:60Z", "second out of range (no leap seconds)"],
+ // Zone offsets: Go's range tests use `>`, so 25 is the first bad hour.
+ ["2026-01-02T15:04:05+25:00", "time zone offset hour out of range"],
+ ["2026-01-02T15:04:05+00:99", "time zone offset minute out of range"]
+ ])("rejects %s (%s), matching Go", async (value) => {
+ const error = await run(Effect.flip(parseExpiry(value)))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toStartWith("parse OAuth token expiry: ")
+ })
+
+ test.each([
+ // Go's general parser is LAXER than its RFC3339 fast path. A strict regex
+ // would wrongly reject all four of these.
+ ["2026-01-02T5:04:05Z", 1767330245000, "one-digit hour (getnum is non-fixed for 15)"],
+ ["2026-01-02T15:04:05,5Z", 1767366245500, "comma sub-second separator"],
+ ["2026-01-02T15:04:05+24:00", 1767279845000, "offset hour 24 (Go tests `> 24`)"],
+ ["2026-01-02T15:04:05+12:60", 1767319445000, "offset minute 60 (Go tests `> 60`)"],
+ ["2024-02-29T00:00:00Z", 1709164800000, "a real leap day"],
+ ["0000-01-01T00:00:00Z", -62167219200000, "year 0 is not shifted into 1900"],
+ ["2026-01-02T15:04:05.123456789Z", 1767366245123, "nanoseconds truncate to millis"]
+ ])("accepts %s -> %d (%s), matching Go", async (value, expected) => {
+ expect(await run(parseExpiry(value))).toBe(expected)
+ })
+
+ test("the failure message is byte-identical to Go's ParseError", async () => {
+ const error = await run(Effect.flip(parseExpiry("yesterday")))
+ expect(error.message).toBe(
+ 'parse OAuth token expiry: parsing time "yesterday" as ' +
+ '"2006-01-02T15:04:05Z07:00": cannot parse "yesterday" as "2006"'
+ )
+ })
+
+ test("range failures use Go's short ParseError form, with no layout echo", async () => {
+ const error = await run(Effect.flip(parseExpiry("2026-02-30T00:00:00Z")))
+ expect(error.message).toBe(
+ 'parse OAuth token expiry: parsing time "2026-02-30T00:00:00Z": day out of range'
+ )
+ })
+
+ test("trailing junk is reported as Go's `extra text`", async () => {
+ const error = await run(Effect.flip(parseExpiry("2026-01-02T15:04:05Zextra")))
+ expect(error.message).toBe(
+ 'parse OAuth token expiry: parsing time "2026-01-02T15:04:05Zextra": extra text: "extra"'
+ )
+ })
+
+ test("truncated input reports the element that ran off the end", async () => {
+ // Go walks the layout, so the empty remainder fails against the next verb.
+ expect((await run(Effect.flip(parseExpiry("2026-01-02")))).message).toBe(
+ 'parse OAuth token expiry: parsing time "2026-01-02" as ' +
+ '"2006-01-02T15:04:05Z07:00": cannot parse "" as "T"'
+ )
+ })
+
+ test("non-ASCII is quoted as UTF-8 BYTES, Go's time.quote, not strconv.Quote", async () => {
+ // strconv.Quote would emit "café" verbatim; time.quote escapes each byte.
+ const error = await run(Effect.flip(parseExpiry("café")))
+ expect(error.message).toBe(
+ 'parse OAuth token expiry: parsing time "caf\\xc3\\xa9" as ' +
+ '"2006-01-02T15:04:05Z07:00": cannot parse "caf\\xc3\\xa9" as "2006"'
+ )
+ })
+
+ test("a tab is \\x09, not \\t (time.quote has no short escapes)", async () => {
+ const error = await run(Effect.flip(parseExpiry("a\tb")))
+ expect(error.message).toContain('"a\\x09b"')
+ })
+
+ test("quotes and backslashes take a single backslash", async () => {
+ expect((await run(Effect.flip(parseExpiry('a"b')))).message).toContain('"a\\"b"')
+ expect((await run(Effect.flip(parseExpiry("a\\b")))).message).toContain('"a\\\\b"')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Config defaults
+// ---------------------------------------------------------------------------
+
+describe("withDefaults", () => {
+ test("fills the Google endpoints and the 3-minute login timeout", () => {
+ const resolved = withDefaults({})
+ expect(resolved.authorizationUrl).toBe("https://accounts.google.com/o/oauth2/v2/auth")
+ expect(resolved.tokenUrl).toBe("https://oauth2.googleapis.com/token")
+ expect(resolved.revokeUrl).toBe("https://oauth2.googleapis.com/revoke")
+ expect(resolved.loginTimeoutMillis).toBe(180_000)
+ expect(resolved.httpTimeoutMillis).toBe(20_000)
+ })
+
+ test("non-positive durations fall back, matching Go's `<= 0` guard", () => {
+ expect(withDefaults({ loginTimeoutMillis: 0 }).loginTimeoutMillis).toBe(180_000)
+ expect(withDefaults({ httpTimeoutMillis: -1 }).httpTimeoutMillis).toBe(20_000)
+ })
+
+ test("explicit overrides survive", () => {
+ expect(withDefaults({ tokenUrl: "http://local/token" }).tokenUrl).toBe("http://local/token")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Service wiring — CredentialStore is consumed BY TAG and mocked here; P4 owns
+// the real implementation.
+// ---------------------------------------------------------------------------
+
+const storedOAuth = (overrides: Partial = {}): StoredOAuth => ({
+ clientId: "client-id",
+ clientSecret: "client-secret",
+ accessToken: "stored-access",
+ refreshToken: "stored-refresh",
+ // Long past, so every call refreshes unless overridden.
+ expiry: "2020-01-01T00:00:00Z",
+ scopes: ["scope.one"],
+ ...overrides
+})
+
+interface StoreSpy {
+ readonly saves: Array<{ readonly expected: StoredOAuth | undefined; readonly next: StoredOAuth }>
+}
+
+/** Only the members this service touches are implemented; the rest die loudly. */
+const mockStore = (
+ oauth: StoredOAuth | undefined,
+ options: { readonly casResult?: boolean } = {}
+): { readonly layer: Layer.Layer; readonly spy: StoreSpy } => {
+ const spy: StoreSpy = { saves: [] }
+ const unimplemented = (name: string) =>
+ Effect.die(new Error(`CredentialStore.${name} must not be called by OAuthService`))
+ const shape: CredentialStoreShape = {
+ dir: unimplemented("dir"),
+ path: unimplemented("path"),
+ load: Effect.succeed({ key: "", source: "", oauth, path: "/tmp/auth.json" } as Credentials),
+ save: () => unimplemented("save"),
+ saveOAuth: () => unimplemented("saveOAuth"),
+ saveRefreshedOAuth: (expected, next) =>
+ Effect.sync(() => {
+ spy.saves.push({ expected, next })
+ return options.casResult ?? true
+ }),
+ clearOAuth: unimplemented("clearOAuth"),
+ remove: unimplemented("remove"),
+ fingerprint: () => "sha256:000000000000",
+ envKeySet: Effect.succeed(false),
+ oauthBootstrap: Effect.succeed(["", ""] as const)
+ }
+ return { layer: Layer.succeed(CredentialStore, shape), spy }
+}
+
+const browserLayer = (opened: Array): Layer.Layer =>
+ Layer.succeed(BrowserOpener, { open: (url) => Effect.sync(() => void opened.push(url)) })
+
+/**
+ * Wires a service instance over the mock store, the fake browser, and fetch.
+ * `use` runs `f` against the built shape, so each test drives the real service
+ * rather than the underlying free functions.
+ */
+const service = (
+ endpoints: {
+ readonly tokenUrl?: string
+ readonly revokeUrl?: string
+ readonly authorizationUrl?: string
+ },
+ stored: StoredOAuth | undefined,
+ options: { readonly casResult?: boolean; readonly opener?: BrowserOpenerShape } = {}
+) => {
+ const { layer, spy } = mockStore(stored, options)
+ const opened: Array = []
+ const use = (f: (shape: OAuthServiceShape) => Effect.Effect): Effect.Effect =>
+ Effect.flatMap(makeOAuthService(endpoints), f).pipe(
+ Effect.provide(layer),
+ Effect.provide(
+ options.opener === undefined
+ ? browserLayer(opened)
+ : Layer.succeed(BrowserOpener, options.opener)
+ ),
+ Effect.provide(FetchHttpClient.layer)
+ )
+ return { use, spy, opened }
+}
+
+describe("OAuthService.tokenSource", () => {
+ test("refreshes a stale stored token and persists it through the CAS", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"fresh-access","expires_in":3600,"token_type":"Bearer"}`)
+ )
+ const wired = service({ tokenUrl: server.url }, storedOAuth())
+ const access = await Effect.runPromise(
+ wired.use((shape) => shape.tokenSource(false))
+ )
+ expect(Redacted.value(access)).toBe("fresh-access")
+
+ const form = server.requests[0]!.form
+ expect(form.get("grant_type")).toBe("refresh_token")
+ expect(form.get("refresh_token")).toBe("stored-refresh")
+ expect(form.get("client_id")).toBe("client-id")
+ expect(form.get("client_secret")).toBe("client-secret")
+
+ expect(wired.spy.saves).toHaveLength(1)
+ expect(wired.spy.saves[0]!.expected).toEqual(storedOAuth())
+ expect(wired.spy.saves[0]!.next.accessToken).toBe("fresh-access")
+ // The response omitted both, so both are inherited.
+ expect(wired.spy.saves[0]!.next.refreshToken).toBe("stored-refresh")
+ expect(wired.spy.saves[0]!.next.scopes).toEqual(["scope.one"])
+ expect(wired.spy.saves[0]!.next.expiry).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
+ })
+
+ test("the source is built once: a second call reuses the cached token", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"fresh-access","expires_in":3600,"token_type":"Bearer"}`)
+ )
+ const wired = service({ tokenUrl: server.url }, storedOAuth())
+ const tokens = await Effect.runPromise(
+ wired.use((shape) =>
+ Effect.gen(function* () {
+ const first = yield* shape.tokenSource(false)
+ const second = yield* shape.tokenSource(false)
+ return [Redacted.value(first), Redacted.value(second)] as const
+ })
+ )
+ )
+ expect(tokens).toEqual(["fresh-access", "fresh-access"])
+ expect(server.requests).toHaveLength(1)
+ })
+
+ test("force re-refreshes even when the cached token is fresh (the post-401 path)", async () => {
+ let calls = 0
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"${++calls === 1 ? "first" : "second"}","expires_in":3600}`)
+ )
+ const wired = service({ tokenUrl: server.url }, storedOAuth())
+ const access = await Effect.runPromise(
+ wired.use((shape) =>
+ Effect.gen(function* () {
+ yield* shape.tokenSource(false)
+ return Redacted.value(yield* shape.tokenSource(true))
+ })
+ )
+ )
+ expect(access).toBe("second")
+ expect(server.requests).toHaveLength(2)
+ // The second CAS expects the FIRST refresh's value, not the original.
+ expect(wired.spy.saves[1]!.expected!.accessToken).toBe("first")
+ })
+
+ test("a losing CAS (logout won the race) neither fails nor advances the snapshot", async () => {
+ let calls = 0
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"${++calls === 1 ? "first" : "second"}","expires_in":3600}`)
+ )
+ const wired = service({ tokenUrl: server.url }, storedOAuth(), { casResult: false })
+ const access = await Effect.runPromise(
+ wired.use((shape) =>
+ Effect.gen(function* () {
+ yield* shape.tokenSource(false)
+ return Redacted.value(yield* shape.tokenSource(true))
+ })
+ )
+ )
+ expect(access).toBe("second")
+ // The snapshot never advanced, so both attempts carry the ORIGINAL value.
+ expect(wired.spy.saves[0]!.expected).toEqual(storedOAuth())
+ expect(wired.spy.saves[1]!.expected).toEqual(storedOAuth())
+ })
+
+ test("no stored OAuth credentials yields MissingOAuthError", async () => {
+ const wired = service({ tokenUrl: "http://127.0.0.1:1/token" }, undefined)
+ const error = await Effect.runPromise(
+ Effect.flip(wired.use((shape) => shape.tokenSource(false)))
+ )
+ expect(error).toBeInstanceOf(MissingOAuthError)
+ expect(error.message).toBe("no OAuth credentials configured; run 'oytc login --oauth'")
+ })
+
+ test("a still-valid stored token is served without any network call", async () => {
+ const server = startServer(() => tokenJson(`{"access_token":"unused"}`))
+ const wired = service(
+ { tokenUrl: server.url },
+ storedOAuth({ expiry: formatExpiry(Date.now() + 3_600_000), accessToken: "still-good" })
+ )
+ const access = await Effect.runPromise(
+ wired.use((shape) => shape.tokenSource(false))
+ )
+ expect(Redacted.value(access)).toBe("still-good")
+ expect(server.requests).toHaveLength(0)
+ })
+
+ test("concurrent callers share one source and issue one refresh", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"fresh","expires_in":3600,"token_type":"Bearer"}`)
+ )
+ const wired = service({ tokenUrl: server.url }, storedOAuth())
+ const tokens = await Effect.runPromise(
+ wired.use((shape) =>
+ Effect.map(
+ Effect.all([shape.tokenSource(false), shape.tokenSource(false)], {
+ concurrency: "unbounded"
+ }),
+ (values) => values.map(Redacted.value)
+ )
+ )
+ )
+ expect(tokens).toEqual(["fresh", "fresh"])
+ expect(server.requests).toHaveLength(1)
+ })
+
+ test("a corrupt stored expiry surfaces as an operational error", async () => {
+ const wired = service({ tokenUrl: "http://127.0.0.1:1" }, storedOAuth({ expiry: "yesterday" }))
+ const error = await Effect.runPromise(
+ Effect.flip(wired.use((shape) => shape.tokenSource(false)))
+ )
+ expect(error.message).toStartWith("parse OAuth token expiry: ")
+ })
+})
+
+describe("OAuthService.revoke", () => {
+ const revoking = (stored: StoredOAuth, revokeUrl: string) => {
+ const wired = service({ revokeUrl }, stored)
+ return wired.use((shape) => shape.revoke(stored))
+ }
+
+ test("prefers the refresh token", async () => {
+ const server = startServer(() => new Response("", { status: 200 }))
+ await Effect.runPromise(revoking(storedOAuth(), server.url))
+ expect(server.requests[0]!.form.get("token")).toBe("stored-refresh")
+ })
+
+ test("falls back to the access token when the refresh token is empty", async () => {
+ const server = startServer(() => new Response("", { status: 200 }))
+ await Effect.runPromise(revoking(storedOAuth({ refreshToken: "" }), server.url))
+ expect(server.requests[0]!.form.get("token")).toBe("stored-access")
+ })
+
+ test("a failing revoke endpoint is swallowed — logout must still proceed", async () => {
+ const server = startServer(
+ () =>
+ new Response(`{"error":"invalid_token"}`, {
+ status: 400,
+ headers: { "Content-Type": "application/json" }
+ })
+ )
+ const exit = await Effect.runPromiseExit(revoking(storedOAuth(), server.url))
+ expect(exit._tag).toBe("Success")
+ })
+
+ test("an unreachable revoke endpoint is also swallowed", async () => {
+ const exit = await Effect.runPromiseExit(revoking(storedOAuth(), "http://127.0.0.1:1/revoke"))
+ expect(exit._tag).toBe("Success")
+ })
+})
+
+describe("OAuthService.refresh", () => {
+ test("returns a StoredOAuth carrying the original client credentials", async () => {
+ const server = startServer(() =>
+ tokenJson(`{"access_token":"new","expires_in":3600,"scope":"a b","token_type":"Bearer"}`)
+ )
+ const stored = storedOAuth()
+ const wired = service({ tokenUrl: server.url }, stored)
+ const updated = await Effect.runPromise(
+ wired.use((shape) => shape.refresh(stored))
+ )
+ expect(updated.clientId).toBe("client-id")
+ expect(updated.clientSecret).toBe("client-secret")
+ expect(updated.accessToken).toBe("new")
+ expect(updated.refreshToken).toBe("stored-refresh")
+ expect(updated.scopes).toEqual(["a", "b"])
+ })
+})
+
+describe("OAuthService.login", () => {
+ test("drives the browser, exchanges the code, and returns a StoredOAuth", async () => {
+ const server = startServer(() =>
+ tokenJson(
+ `{"access_token":"access","refresh_token":"refresh","expires_in":3600,` +
+ `"scope":"granted.one granted.two","token_type":"Bearer"}`
+ )
+ )
+ const opened: Array = []
+ const wired = service(
+ { tokenUrl: server.url, authorizationUrl: "https://accounts.example/auth" },
+ undefined,
+ {
+ opener: {
+ open: (url) =>
+ Effect.sync(() => {
+ opened.push(url)
+ const parsed = new URL(url)
+ const redirect = parsed.searchParams.get("redirect_uri")!
+ const state = encodeURIComponent(parsed.searchParams.get("state")!)
+ void callback(`${redirect}?code=the-code&state=${state}`)
+ })
+ }
+ }
+ )
+
+ const { announced, stored } = await Effect.runPromise(
+ Effect.gen(function* () {
+ const result = yield* wired.use((shape) =>
+ shape.login({ clientId: "cli-id", clientSecret: Redacted.make("cli-secret") })
+ )
+ return { stored: result, announced: yield* TestConsole.errorLines }
+ }).pipe(Effect.provide(TestConsole.layer))
+ )
+
+ // Go prints this to cfg.Out, which the CLI wires to stderr.
+ expect(announced).toHaveLength(1)
+ expect(String(announced[0])).toStartWith("Open this URL to authorize oytc:\n")
+
+ expect(stored.clientId).toBe("cli-id")
+ expect(stored.clientSecret).toBe("cli-secret")
+ expect(stored.accessToken).toBe("access")
+ expect(stored.refreshToken).toBe("refresh")
+ expect(stored.scopes).toEqual(["granted.one", "granted.two"])
+ expect(stored.expiry).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
+
+ // The default scopes are requested, in order.
+ expect(new URL(opened[0]!).searchParams.get("scope")).toBe(
+ "https://www.googleapis.com/auth/yt-analytics.readonly" +
+ " https://www.googleapis.com/auth/youtube.readonly"
+ )
+ expect(server.requests[0]!.form.get("code")).toBe("the-code")
+ })
+})
diff --git a/src/impl/oauth.ts b/src/impl/oauth.ts
index ca14f9a..7f1c1b6 100644
--- a/src/impl/oauth.ts
+++ b/src/impl/oauth.ts
@@ -1,2 +1,987 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * OAuth 2.0 loopback redirect flow (authorization code + PKCE S256).
+ *
+ * The Go implementation wraps `golang.org/x/oauth2`; this port reproduces the
+ * WIRE behavior of that library rather than its API, because the wire behavior
+ * is what Google sees and what the tests assert. In particular:
+ *
+ * - `AuthStyle` is pinned to `AuthStyleInParams`: `client_id` and
+ * `client_secret` go in the POST body, never `Authorization: Basic`. Go
+ * pins this to avoid the library's two-request auto-detection probe.
+ * - Token responses are dispatched on `Content-Type` exactly as
+ * `x/oauth2/internal.doTokenRoundTrip` does — form/text-plain bodies are
+ * parsed as a query string, everything else as JSON — because the two
+ * branches produce *different* error descriptions for the same body, and
+ * `oauth_test.go` exercises both (`TestExchangeErrorWithoutContentType`
+ * reaches the form branch because Go's httptest server content-sniffs an
+ * unlabelled body to `text/plain`).
+ * - Expiry is stamped from `expires_in` against the clock, and both the stamp
+ * and the TokenSource skew check read the SAME clock. Go split these (real
+ * clock for stamping, `cfg.Now` for checks); using one Effect `Clock` is
+ * identical in production and strictly more coherent under a TestClock.
+ *
+ * Non-obvious invariants worth keeping:
+ * - `redirectURI` has NO trailing slash and NO path. It is sent byte-identical
+ * in the authorization request and the token exchange; Google compares them.
+ * - The 3-minute login timeout covers ONLY the wait for the browser callback.
+ * The token exchange that follows runs under the caller's timeout.
+ */
+
+import { Clock, Console, Effect, Layer, Redacted, Ref, type Scope, Semaphore } from "effect"
+import { HttpClient, HttpClientRequest } from "../effect.ts"
+import { MissingOAuthError, OAuthError, OperationalError, statusText } from "../domain/errors.ts"
+import {
+ BrowserOpener,
+ type BrowserOpenerShape,
+ CredentialStore,
+ type CredentialStoreShape,
+ OAuthService,
+ type OAuthLoginRequest,
+ type OAuthServiceShape,
+ type StoredOAuth
+} from "../services/index.ts"
+import { acquireLoopbackServer } from "./oauthServer.ts"
+import { makeTokenSource, type TokenSourceHandle } from "./tokenSource.ts"
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+export const DEFAULT_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
+export const DEFAULT_TOKEN_URL = "https://oauth2.googleapis.com/token"
+export const DEFAULT_REVOKE_URL = "https://oauth2.googleapis.com/revoke"
+
+/** `DefaultLoginTimeout`; the CLI also passes 3m explicitly. */
+export const DEFAULT_LOGIN_TIMEOUT_MILLIS = 3 * 60 * 1000
+
+/** Go's default `http.Client{Timeout: 20 * time.Second}` when none is injected. */
+export const DEFAULT_HTTP_TIMEOUT_MILLIS = 20_000
+
+/** `io.ReadAll(io.LimitReader(body, 1<<20))` — both in x/oauth2 and in `Revoke`. */
+const BODY_LIMIT_BYTES = 1 << 20
+
+/**
+ * Scopes, in this exact order (internal/cli/auth.go). Analytics reports plus
+ * read-only Data API access, so an OAuth-only setup can also run every
+ * public-data command. `youtube.readonly` is classified *sensitive*: unverified
+ * apps requesting it are hard-blocked for accounts with Advanced Protection or
+ * restrictive Workspace policies.
+ */
+export const DEFAULT_SCOPES: ReadonlyArray = [
+ "https://www.googleapis.com/auth/yt-analytics.readonly",
+ "https://www.googleapis.com/auth/youtube.readonly"
+]
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface OAuthToken {
+ readonly accessToken: string
+ readonly refreshToken: string
+ /** Millis since the epoch. `0` is Go's zero `time.Time` — "no expiry known". */
+ readonly expiryMillis: number
+ readonly scopes: ReadonlyArray
+}
+
+export interface OAuthEndpoints {
+ readonly authorizationUrl: string
+ readonly tokenUrl: string
+ readonly revokeUrl: string
+}
+
+export interface OAuthConfig extends OAuthEndpoints {
+ readonly clientId: string
+ readonly clientSecret: string
+ readonly scopes: ReadonlyArray
+ readonly httpTimeoutMillis: number
+ readonly loginTimeoutMillis: number
+}
+
+export const defaultEndpoints: OAuthEndpoints = {
+ authorizationUrl: DEFAULT_AUTHORIZATION_URL,
+ tokenUrl: DEFAULT_TOKEN_URL,
+ revokeUrl: DEFAULT_REVOKE_URL
+}
+
+/** `withDefaults(cfg)` — empty strings and non-positive durations fall back. */
+export const withDefaults = (config: Partial): OAuthConfig => ({
+ clientId: config.clientId ?? "",
+ clientSecret: config.clientSecret ?? "",
+ scopes: config.scopes ?? [],
+ authorizationUrl:
+ config.authorizationUrl === undefined || config.authorizationUrl === ""
+ ? DEFAULT_AUTHORIZATION_URL
+ : config.authorizationUrl,
+ tokenUrl:
+ config.tokenUrl === undefined || config.tokenUrl === ""
+ ? DEFAULT_TOKEN_URL
+ : config.tokenUrl,
+ revokeUrl:
+ config.revokeUrl === undefined || config.revokeUrl === ""
+ ? DEFAULT_REVOKE_URL
+ : config.revokeUrl,
+ httpTimeoutMillis:
+ config.httpTimeoutMillis === undefined || config.httpTimeoutMillis <= 0
+ ? DEFAULT_HTTP_TIMEOUT_MILLIS
+ : config.httpTimeoutMillis,
+ loginTimeoutMillis:
+ config.loginTimeoutMillis === undefined || config.loginTimeoutMillis <= 0
+ ? DEFAULT_LOGIN_TIMEOUT_MILLIS
+ : config.loginTimeoutMillis
+})
+
+/**
+ * `OAuth authorization is expired or revoked; re-run 'oytc login --oauth': `.
+ *
+ * Go wraps the `*oauth.Error` with `%w`, so `main.go`'s `errors.As` still finds
+ * it and still exits 3. Subclassing keeps `_tag: "OAuthError"` and therefore the
+ * same exit-code derivation, while replacing the rendered message.
+ */
+export class ExpiredAuthorizationError extends OAuthError {
+ override get message(): string {
+ const code = this.code === "" ? "unknown" : this.code
+ const inner =
+ this.description === ""
+ ? `OAuth error (${code})`
+ : `OAuth error (${code}): ${this.description}`
+ return `OAuth authorization is expired or revoked; re-run 'oytc login --oauth': ${inner}`
+ }
+}
+
+/**
+ * `OAuth refresh token is missing; re-run 'oytc login --oauth'`.
+ *
+ * Go returns a bare `errors.New`, which `main.go` classifies as exit 3 purely by
+ * the `re-run 'oytc login --oauth'` substring rule. Modelling it as an
+ * `OAuthError` with an empty code lands on the same exit 3 through the
+ * structured path, and keeps it inside the error union `refresh` declares.
+ */
+export class MissingRefreshTokenError extends OAuthError {
+ constructor() {
+ super({ httpStatus: 0, code: "", description: "" })
+ }
+ override get message(): string {
+ return "OAuth refresh token is missing; re-run 'oytc login --oauth'"
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Randomness, PKCE
+// ---------------------------------------------------------------------------
+
+const base64UrlNoPad = (bytes: Uint8Array): string => {
+ let binary = ""
+ for (const byte of bytes) binary += String.fromCharCode(byte)
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")
+}
+
+/**
+ * `base64.RawURLEncoding.EncodeToString(rand(32))` — 43 characters.
+ * Used for both `state` and the PKCE verifier (`oauth2.GenerateVerifier`).
+ */
+export const randomUrlSafe = (byteLength: number): Effect.Effect =>
+ Effect.try({
+ try: () => base64UrlNoPad(crypto.getRandomValues(new Uint8Array(byteLength))),
+ catch: (cause) =>
+ new OperationalError({
+ message: `generate OAuth random value: ${describe(cause)}`,
+ cause
+ })
+ })
+
+/** `code_challenge = base64url_nopad(sha256(verifier))`, method `S256`. */
+export const pkceChallenge = (verifier: string): Effect.Effect =>
+ Effect.tryPromise({
+ try: async () =>
+ base64UrlNoPad(
+ new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
+ ),
+ catch: (cause) =>
+ new OperationalError({
+ message: `generate OAuth PKCE challenge: ${describe(cause)}`,
+ cause
+ })
+ })
+
+// ---------------------------------------------------------------------------
+// Authorization URL
+// ---------------------------------------------------------------------------
+
+/**
+ * Go's `url.Values.Encode()` sorts keys before joining. Percent-encoding is
+ * byte-identical to `URLSearchParams` for every value this flow produces
+ * (base64url state/challenge, an `https://` scope list, a loopback redirect URI,
+ * a Google client id): the two disagree only on `~ ! * ( ) '`, none of which
+ * occur. Sorting keeps the emitted URL byte-comparable with the Go binary.
+ */
+const encodeForm = (pairs: ReadonlyArray): string => {
+ const sorted = [...pairs].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ return new URLSearchParams(sorted as Array<[string, string]>).toString()
+}
+
+export const authorizationUrl = (
+ config: OAuthConfig,
+ redirectUri: string,
+ state: string,
+ challenge: string
+): string => {
+ const params: Array = [
+ ["response_type", "code"],
+ ["client_id", config.clientId]
+ ]
+ if (redirectUri !== "") params.push(["redirect_uri", redirectUri])
+ if (config.scopes.length > 0) params.push(["scope", config.scopes.join(" ")])
+ if (state !== "") params.push(["state", state])
+ params.push(["access_type", "offline"])
+ params.push(["code_challenge_method", "S256"])
+ params.push(["code_challenge", challenge])
+ // Forces the consent screen so Google ALWAYS returns a refresh token, not
+ // only on the first authorization.
+ params.push(["prompt", "consent"])
+
+ const separator = config.authorizationUrl.includes("?") ? "&" : "?"
+ return `${config.authorizationUrl}${separator}${encodeForm(params)}`
+}
+
+// ---------------------------------------------------------------------------
+// Token endpoint
+// ---------------------------------------------------------------------------
+
+const describe = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause))
+
+const decodeBody = (bytes: Uint8Array): string =>
+ new TextDecoder().decode(bytes.subarray(0, BODY_LIMIT_BYTES))
+
+/** `mime.ParseMediaType` reduced to what the dispatch needs. */
+const mediaType = (contentType: string | undefined): string =>
+ (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? ""
+
+const asString = (value: unknown): string => (typeof value === "string" ? value : "")
+
+/** `expirationTime.UnmarshalJSON` — numbers or numeric strings, clamped to int32. */
+const asExpiresIn = (value: unknown): number => {
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN
+ if (!Number.isFinite(n)) return 0
+ return Math.min(Math.trunc(n), 2147483647)
+}
+
+interface TokenPayload {
+ readonly accessToken: string
+ readonly refreshToken: string
+ readonly expiresInSeconds: number
+ readonly scope: string
+ readonly errorCode: string
+ readonly errorDescription: string
+ /** The body could not be decoded in the branch its content type selected. */
+ readonly undecodable: boolean
+}
+
+const parseFormPayload = (body: string): TokenPayload => {
+ const values = new URLSearchParams(body)
+ return {
+ accessToken: values.get("access_token") ?? "",
+ refreshToken: values.get("refresh_token") ?? "",
+ expiresInSeconds: asExpiresIn(values.get("expires_in") ?? ""),
+ scope: values.get("scope") ?? "",
+ errorCode: values.get("error") ?? "",
+ errorDescription: values.get("error_description") ?? "",
+ undecodable: false
+ }
+}
+
+const parseJsonPayload = (body: string): TokenPayload => {
+ let decoded: unknown
+ try {
+ decoded = JSON.parse(body)
+ } catch {
+ return {
+ accessToken: "",
+ refreshToken: "",
+ expiresInSeconds: 0,
+ scope: "",
+ errorCode: "",
+ errorDescription: "",
+ undecodable: true
+ }
+ }
+ const record = (
+ typeof decoded === "object" && decoded !== null ? decoded : {}
+ ) as Record
+ return {
+ accessToken: asString(record["access_token"]),
+ refreshToken: asString(record["refresh_token"]),
+ expiresInSeconds: asExpiresIn(record["expires_in"]),
+ scope: asString(record["scope"]),
+ errorCode: asString(record["error"]),
+ errorDescription: asString(record["error_description"]),
+ undecodable: false
+ }
+}
+
+/**
+ * `doTokenRoundTrip`'s content-type dispatch. Form/text-plain bodies are read as
+ * a query string — which is why an unlabelled JSON error body yields an EMPTY
+ * error code there and has to be re-parsed by {@link parseErrorBody}.
+ */
+const parseTokenPayload = (contentType: string | undefined, body: string): TokenPayload => {
+ const type = mediaType(contentType)
+ return type === "application/x-www-form-urlencoded" || type === "text/plain"
+ ? parseFormPayload(body)
+ : parseJsonPayload(body)
+}
+
+/**
+ * `parseError(status, body)`: JSON-decode `{error, error_description}`; an empty
+ * code falls back to the HTTP status text, an empty description to the trimmed
+ * raw body.
+ */
+export const parseErrorBody = (status: number, body: string): OAuthError => {
+ const payload = parseJsonPayload(body)
+ return new OAuthError({
+ httpStatus: status,
+ code: payload.errorCode === "" ? statusText(status) : payload.errorCode,
+ description: payload.errorDescription === "" ? body.trim() : payload.errorDescription
+ })
+}
+
+interface RetrievedToken {
+ readonly accessToken: string
+ readonly refreshToken: string
+ readonly expiryMillis: number
+ readonly scope: string
+}
+
+const postForm = (
+ action: string,
+ url: string,
+ form: ReadonlyArray,
+ headers: ReadonlyArray,
+ timeoutMillis: number
+): Effect.Effect<
+ { readonly status: number; readonly contentType: string | undefined; readonly body: string },
+ OperationalError,
+ HttpClient.HttpClient
+> =>
+ Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+ // Sorted, so the emitted body is byte-identical to Go's url.Values.Encode.
+ let request = HttpClientRequest.post(url).pipe(
+ HttpClientRequest.bodyText(encodeForm(form), "application/x-www-form-urlencoded")
+ )
+ for (const [name, value] of headers) {
+ request = HttpClientRequest.setHeader(request, name, value)
+ }
+ const response = yield* client.execute(request)
+ const buffer = yield* response.arrayBuffer
+ return {
+ status: response.status,
+ contentType: response.headers["content-type"],
+ body: decodeBody(new Uint8Array(buffer))
+ }
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: timeoutMillis,
+ orElse: () =>
+ Effect.fail(
+ new OperationalError({
+ message: `${action}: Client.Timeout exceeded while awaiting headers`
+ })
+ )
+ }),
+ // translateError: a *url.Error is unwrapped to `: `.
+ Effect.catch((cause) =>
+ cause instanceof OperationalError
+ ? Effect.fail(cause)
+ : Effect.fail(
+ new OperationalError({ message: `${action}: ${describe(cause)}`, cause })
+ )
+ )
+ )
+
+/** `retrieveToken` + `translateError`, collapsed. */
+const retrieveToken = (
+ action: string,
+ config: OAuthConfig,
+ form: ReadonlyArray
+): Effect.Effect =>
+ Effect.gen(function* () {
+ // AuthStyleInParams: credentials in the POST body, never Basic auth.
+ const body: Array = [...form]
+ if (config.clientId !== "") body.push(["client_id", config.clientId])
+ if (config.clientSecret !== "") body.push(["client_secret", config.clientSecret])
+
+ const response = yield* postForm(action, config.tokenUrl, body, [], config.httpTimeoutMillis)
+ const failureStatus = response.status < 200 || response.status > 299
+ const payload = parseTokenPayload(response.contentType, response.body)
+
+ if (payload.undecodable) {
+ return yield* Effect.fail(
+ failureStatus
+ ? parseErrorBody(response.status, response.body)
+ : new OperationalError({ message: `oauth2: cannot parse json: ${response.body}` })
+ )
+ }
+
+ if (failureStatus || payload.errorCode !== "") {
+ return yield* Effect.fail(
+ payload.errorCode === ""
+ ? parseErrorBody(response.status, response.body)
+ : new OAuthError({
+ httpStatus: response.status,
+ code: payload.errorCode,
+ description: payload.errorDescription
+ })
+ )
+ }
+
+ if (payload.accessToken === "") {
+ return yield* Effect.fail(
+ new OperationalError({ message: "oauth2: server response missing access_token" })
+ )
+ }
+
+ const now = yield* Clock.currentTimeMillis
+ // Don't overwrite an empty RefreshToken on a refresh_token grant.
+ const requested = body.find(([key]) => key === "refresh_token")?.[1] ?? ""
+ return {
+ accessToken: payload.accessToken,
+ refreshToken: payload.refreshToken === "" ? requested : payload.refreshToken,
+ expiryMillis:
+ payload.expiresInSeconds === 0 ? 0 : now + payload.expiresInSeconds * 1000,
+ scope: payload.scope
+ }
+ })
+
+/**
+ * `fromLibrary`: inherit the refresh token and the scopes when the response
+ * omits them, falling back to the configured scopes only as a last resort.
+ */
+const normalizeToken = (
+ retrieved: RetrievedToken,
+ config: OAuthConfig,
+ current: OAuthToken
+): OAuthToken => {
+ // strings.Fields: split on any whitespace run, no empty elements.
+ const granted = retrieved.scope.split(/\s+/).filter((part) => part !== "")
+ const scopes =
+ granted.length > 0 ? granted : current.scopes.length > 0 ? [...current.scopes] : [...config.scopes]
+ return {
+ accessToken: retrieved.accessToken,
+ refreshToken: retrieved.refreshToken === "" ? current.refreshToken : retrieved.refreshToken,
+ expiryMillis: retrieved.expiryMillis,
+ scopes
+ }
+}
+
+const emptyToken: OAuthToken = {
+ accessToken: "",
+ refreshToken: "",
+ expiryMillis: 0,
+ scopes: []
+}
+
+// ---------------------------------------------------------------------------
+// Exchange / refresh / revoke
+// ---------------------------------------------------------------------------
+
+export const exchange = (
+ config: OAuthConfig,
+ code: string,
+ redirectUri: string,
+ verifier: string
+): Effect.Effect => {
+ const form: Array = [
+ ["grant_type", "authorization_code"],
+ ["code", code]
+ ]
+ if (redirectUri !== "") form.push(["redirect_uri", redirectUri])
+ form.push(["code_verifier", verifier])
+ return Effect.map(retrieveToken("request OAuth token", config, form), (retrieved) =>
+ normalizeToken(retrieved, config, emptyToken)
+ )
+}
+
+export const refresh = (
+ config: OAuthConfig,
+ current: OAuthToken
+): Effect.Effect =>
+ Effect.gen(function* () {
+ if (current.refreshToken.trim() === "") {
+ return yield* Effect.fail(new MissingRefreshTokenError())
+ }
+ const retrieved = yield* retrieveToken("refresh OAuth token", config, [
+ ["grant_type", "refresh_token"],
+ ["refresh_token", current.refreshToken]
+ ])
+ return normalizeToken(retrieved, config, current)
+ })
+
+/**
+ * Best-effort revocation. An empty/whitespace token is a no-op that succeeds,
+ * matching Go; a non-2xx response becomes a structured `OAuthError` that the
+ * caller downgrades to a warning.
+ */
+export const revoke = (
+ config: OAuthConfig,
+ token: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ if (token.trim() === "") return
+ const response = yield* postForm(
+ "revoke OAuth token",
+ config.revokeUrl,
+ [["token", token]],
+ [
+ ["Content-Type", "application/x-www-form-urlencoded"],
+ ["Accept", "application/json"]
+ ],
+ config.httpTimeoutMillis
+ )
+ if (response.status < 200 || response.status >= 300) {
+ return yield* Effect.fail(parseErrorBody(response.status, response.body))
+ }
+ })
+
+// ---------------------------------------------------------------------------
+// Login
+// ---------------------------------------------------------------------------
+
+export interface LoginHooks {
+ /** Receives the authorization URL; stderr in production. */
+ readonly announce: (message: string) => Effect.Effect
+ /** Best-effort browser launch; failures are reported by the opener itself. */
+ readonly openBrowser: (url: string) => Effect.Effect
+}
+
+/**
+ * The full loopback flow. The 3-minute timeout wraps ONLY the wait for the
+ * callback — the exchange that follows inherits the caller's deadline, so a
+ * user who authorizes at 2m59s still gets a token.
+ */
+export const login = (
+ config: OAuthConfig,
+ hooks: LoginHooks
+): Effect.Effect<
+ OAuthToken,
+ OAuthError | OperationalError,
+ HttpClient.HttpClient | Scope.Scope
+> =>
+ Effect.gen(function* () {
+ if (config.clientId.trim() === "") {
+ return yield* Effect.fail(new OperationalError({ message: "OAuth client ID cannot be empty" }))
+ }
+ if (config.clientSecret.trim() === "") {
+ return yield* Effect.fail(
+ new OperationalError({ message: "OAuth client secret cannot be empty" })
+ )
+ }
+
+ const state = yield* randomUrlSafe(32)
+ const verifier = yield* randomUrlSafe(32)
+ const challenge = yield* pkceChallenge(verifier)
+
+ const server = yield* acquireLoopbackServer(state)
+ const target = authorizationUrl(config, server.redirectUri, state, challenge)
+
+ yield* hooks.announce(`Open this URL to authorize oytc:\n${target}`)
+ yield* hooks.openBrowser(target)
+
+ const code = yield* server.awaitCode.pipe(
+ Effect.timeoutOrElse({
+ duration: config.loginTimeoutMillis,
+ orElse: () =>
+ Effect.fail(
+ new OperationalError({ message: "timed out waiting for OAuth authorization" })
+ )
+ })
+ )
+
+ return yield* exchange(config, code, server.redirectUri, verifier)
+ })
+
+// ---------------------------------------------------------------------------
+// Expiry serialization
+// ---------------------------------------------------------------------------
+
+/** The layout string Go names `time.RFC3339`; it appears verbatim in errors. */
+const RFC3339_LAYOUT = "2006-01-02T15:04:05Z07:00"
+
+/**
+ * Go's `time` package has its OWN quoting, which is NOT `strconv.Quote` (and so
+ * NOT `goQuote` from resolveChannel.ts), and is not the JS JSON string encoder
+ * either. Per `time/format.go`'s `quote`, every byte `>= 0x80` or
+ * `< 0x20` is emitted as `\xNN` over its UTF-8 BYTES — so `café` renders as
+ * `caf\xc3\xa9`, a tab as `\x09` (not `\t`), and 🎉 as four `\xNN` escapes.
+ * Only `"` and `\` get a backslash; everything else printable-ASCII is literal.
+ * Verified against Go 1.26 for `café`, `日本`, `a"b`, `a\b`, `a\tb`, `a\x01b`
+ * and an emoji.
+ */
+const timeQuote = (value: string): string => {
+ const bytes = new TextEncoder().encode(value)
+ let out = '"'
+ for (const byte of bytes) {
+ if (byte >= 0x80 || byte < 0x20) {
+ out += `\\x${byte.toString(16).padStart(2, "0")}`
+ } else {
+ if (byte === 0x22 || byte === 0x5c) out += "\\"
+ out += String.fromCharCode(byte)
+ }
+ }
+ return `${out}"`
+}
+
+/** `cannot parse as ` — ParseError with an empty Message. */
+const cannotParse = (value: string, valueElem: string, layoutElem: string): string =>
+ `parsing time ${timeQuote(value)} as ${timeQuote(RFC3339_LAYOUT)}: ` +
+ `cannot parse ${timeQuote(valueElem)} as ${timeQuote(layoutElem)}`
+
+/** `parsing time : ` — ParseError with a non-empty Message. */
+const parseMessage = (value: string, message: string): string =>
+ `parsing time ${timeQuote(value)}: ${message}`
+
+const isDigit = (s: string, i: number): boolean => {
+ const c = s.charCodeAt(i)
+ return c >= 48 && c <= 57
+}
+
+/** Days in a Gregorian month, with Go's leap rule (`isLeap`). */
+const daysIn = (month: number, year: number): number => {
+ if (month === 2 && year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) return 29
+ return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0
+}
+
+/**
+ * `time.Parse(time.RFC3339, value)`, reproduced closely enough that the
+ * accept/reject verdict, the resulting instant, and the error text all match.
+ *
+ * Go tries a strict fast path (`parseRFC3339`) and, when that fails, falls back
+ * to the GENERAL layout parser, which is meaningfully laxer. That fallback is
+ * why several inputs a hand-rolled regex would reject are actually accepted:
+ *
+ * - a ONE-digit hour: `2026-01-02T5:04:05Z` parses (only the hour is lax;
+ * `getnum(value, false)` is non-fixed for `15`, while minute/second use the
+ * zero-padded `04`/`05` verbs and stay fixed at two digits)
+ * - a COMMA sub-second separator: `...05,5Z` parses (`commaOrPeriod`)
+ * - zone offsets up to ±24:60 — Go's own comment says the range tests use `>`
+ * rather than `>=` "as some people do write offsets of 24 hours or 60
+ * minutes", so `+24:00` and `+12:60` are ACCEPTED and only `+25:00` /
+ * `+00:99` are rejected
+ *
+ * And why several a `Date.parse` shortcut would accept are rejected:
+ *
+ * - `2026-02-30`, `2026-06-31`, `2026-02-29` — JS silently rolls these over
+ * into the next month; Go validates against `daysIn` and fails with
+ * `day out of range`
+ * - `2026-01-02T24:00:00Z` — JS accepts hour 24 as the next midnight; Go's
+ * `stdHour` rejects `24 <= hour`
+ *
+ * Error precedence follows `parse`'s loop: a range error for an element beats a
+ * syntax error in a LATER element, `extra text` is reported after the whole
+ * layout is consumed, and the day-of-month check runs last of all.
+ */
+const parseRfc3339 = (value: string): { readonly millis: number } | { readonly message: string } => {
+ // Elements are consumed left to right, so a truncated input naturally fails on
+ // the first element that runs past the end and reports `cannot parse "" as X`,
+ // exactly as Go's layout walk does. No length pre-check is needed (or correct).
+
+ // --- year "2006": exactly four digits, no sign ---
+ if (!/^\d{4}$/.test(value.slice(0, 4))) {
+ return { message: cannotParse(value, value, "2006") }
+ }
+ const year = Number(value.slice(0, 4))
+ let i = 4
+
+ const literal = (ch: string, elem: string): string | undefined =>
+ value[i] === ch ? void (i += 1) : cannotParse(value, value.slice(i), elem)
+
+ let bad = literal("-", "-")
+ if (bad !== undefined) return { message: bad }
+
+ // --- month "01": FIXED two digits, range 1..12 ---
+ if (!isDigit(value, i) || !isDigit(value, i + 1)) {
+ return { message: cannotParse(value, value.slice(i), "01") }
+ }
+ const month = Number(value.slice(i, i + 2))
+ const monthElem = value.slice(i + 2)
+ i += 2
+ if (month <= 0 || month > 12) {
+ return { message: parseMessage(value, "month out of range") }
+ }
+ void monthElem
+
+ bad = literal("-", "-")
+ if (bad !== undefined) return { message: bad }
+
+ // --- day "02": FIXED two digits; the value range is validated at the end ---
+ if (!isDigit(value, i) || !isDigit(value, i + 1)) {
+ return { message: cannotParse(value, value.slice(i), "02") }
+ }
+ const day = Number(value.slice(i, i + 2))
+ i += 2
+
+ bad = literal("T", "T")
+ if (bad !== undefined) return { message: bad }
+
+ // --- hour "15": NON-fixed, so one OR two digits; range 0..23 ---
+ if (!isDigit(value, i)) {
+ return { message: cannotParse(value, value.slice(i), "15") }
+ }
+ const hourWidth = isDigit(value, i + 1) ? 2 : 1
+ const hour = Number(value.slice(i, i + hourWidth))
+ i += hourWidth
+ if (hour < 0 || hour >= 24) {
+ return { message: parseMessage(value, "hour out of range") }
+ }
+
+ bad = literal(":", ":")
+ if (bad !== undefined) return { message: bad }
+
+ // --- minute "04": FIXED two digits; range 0..59 ---
+ if (!isDigit(value, i) || !isDigit(value, i + 1)) {
+ return { message: cannotParse(value, value.slice(i), "04") }
+ }
+ const minute = Number(value.slice(i, i + 2))
+ i += 2
+ if (minute < 0 || minute >= 60) {
+ return { message: parseMessage(value, "minute out of range") }
+ }
+
+ bad = literal(":", ":")
+ if (bad !== undefined) return { message: bad }
+
+ // --- second "05": FIXED two digits; range 0..59 (no leap second) ---
+ if (!isDigit(value, i) || !isDigit(value, i + 1)) {
+ return { message: cannotParse(value, value.slice(i), "05") }
+ }
+ const second = Number(value.slice(i, i + 2))
+ i += 2
+ if (second < 0 || second >= 60) {
+ return { message: parseMessage(value, "second out of range") }
+ }
+
+ // --- fractional second: `.` OR `,` followed by at least one digit ---
+ // Only the first 9 digits contribute to nanoseconds; we need milliseconds,
+ // so the first 3 suffice and the rest are truncated (never rounded).
+ let millisFraction = 0
+ if ((value[i] === "." || value[i] === ",") && isDigit(value, i + 1)) {
+ let n = i + 1
+ while (n < value.length && isDigit(value, n)) n += 1
+ const digits = value.slice(i + 1, n)
+ millisFraction = Number(`${digits.slice(0, 3)}${"0".repeat(Math.max(0, 3 - digits.length))}`)
+ i = n
+ }
+
+ // --- zone "Z07:00": literal `Z`, or ±hh:mm with Go's LAX `>` range tests ---
+ let offsetMinutes = 0
+ if (value[i] === "Z") {
+ i += 1
+ } else {
+ const zone = value.slice(i, i + 6)
+ const sign = zone[0]
+ // Go splits the field FIRST (length and the `:` at index 3 are all it checks
+ // structurally), then reads the two numbers, then range-tests them, and only
+ // afterwards validates the sign. So a bad SIGN with an out-of-range hour —
+ // `...05x25:00` — reports "time zone offset hour out of range", not a parse
+ // error. Order matters here and is verified against Go.
+ if (zone.length !== 6 || zone[3] !== ":") {
+ return { message: cannotParse(value, value.slice(i), "Z07:00") }
+ }
+ const hourDigits = /^\d{2}$/.test(zone.slice(1, 3))
+ const minuteDigits = /^\d{2}$/.test(zone.slice(4, 6))
+ const zoneHour = hourDigits ? Number(zone.slice(1, 3)) : 0
+ const zoneMinute = minuteDigits ? Number(zone.slice(4, 6)) : 0
+ // Go: "The range test use > rather than >=, as some people do write offsets
+ // of 24 hours or 60 minutes or 60 seconds." Both tests assign to the SAME
+ // `rangeErrString`, so when both are out of range the MINUTE message wins.
+ let rangeError = ""
+ if (hourDigits && zoneHour > 24) rangeError = "time zone offset hour out of range"
+ if (minuteDigits && zoneMinute > 60) rangeError = "time zone offset minute out of range"
+ if (rangeError !== "") return { message: parseMessage(value, rangeError) }
+ if (!hourDigits || !minuteDigits || (sign !== "+" && sign !== "-")) {
+ return { message: cannotParse(value, value.slice(i), "Z07:00") }
+ }
+ offsetMinutes = (sign === "-" ? -1 : 1) * (zoneHour * 60 + zoneMinute)
+ i += 6
+ }
+
+ // --- trailing junk, reported once the layout is fully consumed ---
+ if (i !== value.length) {
+ const extra = value.slice(i)
+ return { message: parseMessage(value, `extra text: ${timeQuote(extra)}`) }
+ }
+
+ // --- day-of-month, validated last, exactly as Go does ---
+ if (day < 1 || day > daysIn(month, year)) {
+ return { message: parseMessage(value, "day out of range") }
+ }
+
+ // Date.UTC maps years 0..99 into 1900..1999; setUTCFullYear undoes that so
+ // year 0 stays year 0 (Go accepts "0000-01-01T00:00:00Z").
+ const date = new Date(0)
+ date.setUTCFullYear(year, month - 1, day)
+ date.setUTCHours(hour, minute, second, millisFraction)
+ return { millis: date.getTime() - offsetMinutes * 60_000 }
+}
+
+/** `ParseExpiry` — blank is the zero time (0 millis) and is NOT an error. */
+export const parseExpiry = (value: string): Effect.Effect =>
+ Effect.gen(function* () {
+ if (value.trim() === "") return 0
+ const parsed = parseRfc3339(value)
+ if ("message" in parsed) {
+ return yield* Effect.fail(
+ new OperationalError({ message: `parse OAuth token expiry: ${parsed.message}` })
+ )
+ }
+ return parsed.millis
+ })
+
+/** `FormatExpiry` — the zero time is `""`; otherwise UTC RFC 3339, second precision. */
+export const formatExpiry = (millis: number): string => {
+ if (millis === 0) return ""
+ return `${new Date(millis).toISOString().slice(0, 19)}Z`
+}
+
+// ---------------------------------------------------------------------------
+// Service
+// ---------------------------------------------------------------------------
+
+const configFor = (
+ clientId: string,
+ clientSecret: string,
+ endpoints: Partial
+): OAuthConfig =>
+ withDefaults({
+ clientId,
+ clientSecret,
+ scopes: DEFAULT_SCOPES,
+ ...endpoints
+ })
+
+export const storedFrom = (
+ clientId: string,
+ clientSecret: string,
+ token: OAuthToken
+): StoredOAuth => ({
+ clientId,
+ clientSecret,
+ accessToken: token.accessToken,
+ refreshToken: token.refreshToken,
+ expiry: formatExpiry(token.expiryMillis),
+ scopes: [...token.scopes]
+})
+
+const tokenFrom = (stored: StoredOAuth, expiryMillis: number): OAuthToken => ({
+ accessToken: stored.accessToken,
+ refreshToken: stored.refreshToken,
+ expiryMillis,
+ scopes: [...stored.scopes]
+})
+
+/**
+ * `endpoints` exists purely so tests can point the flow at a local server, the
+ * same role `App.OAuthTokenURL` plays in Go.
+ */
+export const makeOAuthService = (
+ endpoints: Partial = {}
+): Effect.Effect<
+ OAuthServiceShape,
+ never,
+ HttpClient.HttpClient | CredentialStoreShape | BrowserOpenerShape
+> =>
+ Effect.gen(function* () {
+ const client = yield* HttpClient.HttpClient
+ const credentials = yield* CredentialStore
+ const browser = yield* BrowserOpener
+ const cached = yield* Ref.make(undefined)
+ // Guards construction: two concurrent requests must share ONE handle, or
+ // they would each hold their own skew cache and CAS snapshot.
+ const buildGate = yield* Semaphore.make(1)
+
+ const withClient = (effect: Effect.Effect) =>
+ Effect.provideService(effect, HttpClient.HttpClient, client)
+
+ /**
+ * Built lazily and reused: `HttpCore` asks for a token on every request and
+ * forces a refresh after a 401, so the skew cache and the CAS snapshot have
+ * to survive across calls.
+ */
+ const source = Semaphore.withPermit(
+ buildGate,
+ Effect.gen(function* () {
+ const existing = yield* Ref.get(cached)
+ if (existing !== undefined) return existing
+
+ const loaded = yield* credentials.load
+ const stored = loaded.oauth
+ if (stored === undefined) return yield* Effect.fail(new MissingOAuthError({}))
+
+ const config = configFor(stored.clientId, stored.clientSecret, endpoints)
+ const expiryMillis = yield* parseExpiry(stored.expiry)
+ // The CAS snapshot only advances when the store reports a real write, so
+ // a refresh racing a `logout` cannot resurrect removed credentials.
+ const persisted = yield* Ref.make(stored)
+ const handle = makeTokenSource({
+ token: tokenFrom(stored, expiryMillis),
+ refresh: (current) => withClient(refresh(config, current)),
+ onUpdate: (updated) =>
+ Effect.gen(function* () {
+ const expected = yield* Ref.get(persisted)
+ const next = storedFrom(stored.clientId, stored.clientSecret, updated)
+ const saved = yield* credentials.saveRefreshedOAuth(expected, next)
+ if (saved) yield* Ref.set(persisted, next)
+ }).pipe(
+ Effect.catch((cause) =>
+ Effect.fail(
+ cause instanceof OperationalError
+ ? cause
+ : new OperationalError({ message: cause.message, cause })
+ )
+ )
+ )
+ })
+ yield* Ref.set(cached, handle)
+ return handle
+ })
+ )
+
+ return {
+ login: (request: OAuthLoginRequest) =>
+ Effect.gen(function* () {
+ const clientId = request.clientId
+ const clientSecret = Redacted.value(request.clientSecret)
+ const config = configFor(clientId, clientSecret, endpoints)
+ const token = yield* withClient(
+ login(config, {
+ // Go writes this to cfg.Out, which the CLI wires to stderr.
+ announce: Console.error,
+ openBrowser: browser.open
+ })
+ )
+ return storedFrom(clientId, clientSecret, token)
+ }).pipe(Effect.scoped),
+
+ refresh: (stored: StoredOAuth) =>
+ Effect.gen(function* () {
+ const config = configFor(stored.clientId, stored.clientSecret, endpoints)
+ const expiryMillis = yield* parseExpiry(stored.expiry)
+ const token = yield* withClient(refresh(config, tokenFrom(stored, expiryMillis)))
+ return storedFrom(stored.clientId, stored.clientSecret, token)
+ }),
+
+ revoke: (stored: StoredOAuth) => {
+ const config = configFor(stored.clientId, stored.clientSecret, endpoints)
+ const token = stored.refreshToken === "" ? stored.accessToken : stored.refreshToken
+ return withClient(revoke(config, token)).pipe(Effect.ignore)
+ },
+
+ tokenSource: (force: boolean) =>
+ Effect.map(
+ Effect.flatMap(source, (handle) => handle.accessToken(force)),
+ Redacted.make
+ )
+ }
+ })
+
+export const OAuthServiceLive = Layer.effect(OAuthService, makeOAuthService())
diff --git a/src/impl/oauthServer.test.ts b/src/impl/oauthServer.test.ts
new file mode 100644
index 0000000..60bab84
--- /dev/null
+++ b/src/impl/oauthServer.test.ts
@@ -0,0 +1,145 @@
+import { describe, expect, test } from "bun:test"
+import { Deferred, Effect, Exit } from "effect"
+import {
+ acquireLoopbackServer,
+ MISSING_CODE_BODY,
+ MISSING_CODE_MESSAGE,
+ NOT_GRANTED_BODY,
+ STATE_MISMATCH_BODY,
+ SUCCESS_BODY
+} from "./oauthServer.ts"
+import { OAuthError, OperationalError } from "../domain/errors.ts"
+
+const STATE = "state-value"
+
+/**
+ * Runs `body` with a live loopback server, tearing it down afterwards. The
+ * whole thing is scoped so a failing assertion still releases the port.
+ */
+const withServer = (
+ body: (server: {
+ readonly redirectUri: string
+ readonly awaitCode: Effect.Effect
+ }) => Promise
+): Promise =>
+ Effect.runPromise(
+ Effect.gen(function* () {
+ const server = yield* acquireLoopbackServer(STATE)
+ return yield* Effect.promise(() => body(server))
+ }).pipe(Effect.scoped)
+ )
+
+describe("loopback callback server", () => {
+ test("redirect URI has no trailing slash and no path", () =>
+ withServer(async (server) => {
+ expect(server.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
+ }))
+
+ test("success responds with the byte-exact HTML page and resolves the code", () =>
+ withServer(async (server) => {
+ const response = await fetch(`${server.redirectUri}/?code=callback-code&state=${STATE}`)
+ expect(response.status).toBe(200)
+ expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8")
+ expect(await response.text()).toBe(SUCCESS_BODY)
+ expect(await Effect.runPromise(server.awaitCode)).toBe("callback-code")
+ }))
+
+ test("the handler answers on EVERY path, not just /", () =>
+ withServer(async (server) => {
+ const response = await fetch(`${server.redirectUri}/deep/nested/path?code=abc&state=${STATE}`)
+ expect(response.status).toBe(200)
+ expect(await Effect.runPromise(server.awaitCode)).toBe("abc")
+ }))
+
+ test("state mismatch responds 400 and does NOT resolve the flow", () =>
+ withServer(async (server) => {
+ const response = await fetch(`${server.redirectUri}/?code=evil&state=wrong`)
+ expect(response.status).toBe(400)
+ expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff")
+ expect(await response.text()).toBe(STATE_MISMATCH_BODY)
+
+ // Still unresolved: a later, valid callback must be the one that wins.
+ const pending = await Effect.runPromise(
+ Effect.exit(Effect.timeout(server.awaitCode, 50))
+ )
+ expect(Exit.isFailure(pending)).toBe(true)
+
+ await fetch(`${server.redirectUri}/?code=real&state=${STATE}`)
+ expect(await Effect.runPromise(server.awaitCode)).toBe("real")
+ }))
+
+ test("a missing state also fails to match and keeps the flow alive", () =>
+ withServer(async (server) => {
+ const response = await fetch(`${server.redirectUri}/favicon.ico`)
+ expect(response.status).toBe(400)
+ expect(await response.text()).toBe(STATE_MISMATCH_BODY)
+ const pending = await Effect.runPromise(Effect.exit(Effect.timeout(server.awaitCode, 50)))
+ expect(Exit.isFailure(pending)).toBe(true)
+ }))
+
+ test("an error param resolves the flow with a structured OAuthError", () =>
+ withServer(async (server) => {
+ const response = await fetch(
+ `${server.redirectUri}/?error=access_denied&error_description=nope&state=${STATE}`
+ )
+ expect(response.status).toBe(400)
+ expect(await response.text()).toBe(NOT_GRANTED_BODY)
+
+ const error = await Effect.runPromise(Effect.flip(server.awaitCode))
+ expect(error).toBeInstanceOf(OAuthError)
+ expect((error as OAuthError).code).toBe("access_denied")
+ expect((error as OAuthError).description).toBe("nope")
+ }))
+
+ test("a blank code resolves the flow with the missing-code error", () =>
+ withServer(async (server) => {
+ const response = await fetch(`${server.redirectUri}/?code=%20%20&state=${STATE}`)
+ expect(response.status).toBe(400)
+ expect(await response.text()).toBe(MISSING_CODE_BODY)
+ const error = await Effect.runPromise(Effect.flip(server.awaitCode))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toBe(MISSING_CODE_MESSAGE)
+ }))
+
+ test("an absent code param is treated the same as a blank one", () =>
+ withServer(async (server) => {
+ const response = await fetch(`${server.redirectUri}/?state=${STATE}`)
+ expect(response.status).toBe(400)
+ expect(await response.text()).toBe(MISSING_CODE_BODY)
+ const error = await Effect.runPromise(Effect.flip(server.awaitCode))
+ expect(error.message).toBe(MISSING_CODE_MESSAGE)
+ }))
+
+ test("only the FIRST resolution wins; later callbacks are ignored", () =>
+ withServer(async (server) => {
+ await fetch(`${server.redirectUri}/?code=first&state=${STATE}`)
+ await fetch(`${server.redirectUri}/?error=access_denied&state=${STATE}`)
+ await fetch(`${server.redirectUri}/?code=third&state=${STATE}`)
+ expect(await Effect.runPromise(server.awaitCode)).toBe("first")
+ }))
+
+ test("the code is trimmed, matching Go's strings.TrimSpace", () =>
+ withServer(async (server) => {
+ await fetch(`${server.redirectUri}/?code=%20padded%20&state=${STATE}`)
+ expect(await Effect.runPromise(server.awaitCode)).toBe("padded")
+ }))
+
+ test("the port is released when the scope closes", async () => {
+ const uri = await Effect.runPromise(
+ Effect.map(acquireLoopbackServer(STATE), (server) => server.redirectUri).pipe(Effect.scoped)
+ )
+ // A closed listener refuses connections rather than answering.
+ const result = await fetch(`${uri}/?state=${STATE}&code=x`).then(
+ () => "answered",
+ () => "refused"
+ )
+ expect(result).toBe("refused")
+ })
+
+ test("Deferred.doneUnsafe reports false on a second completion (channel semantics)", () => {
+ const deferred = Deferred.makeUnsafe()
+ expect(Deferred.doneUnsafe(deferred, Effect.succeed("a"))).toBe(true)
+ expect(Deferred.doneUnsafe(deferred, Effect.succeed("b"))).toBe(false)
+ })
+})
diff --git a/src/impl/oauthServer.ts b/src/impl/oauthServer.ts
index ca14f9a..d0e420d 100644
--- a/src/impl/oauthServer.ts
+++ b/src/impl/oauthServer.ts
@@ -1,2 +1,138 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * The ephemeral loopback callback server for the OAuth 2.0 authorization-code
+ * flow.
+ *
+ * This is the ONE sanctioned `Bun.*` import outside `main.ts`. `BunHttpServer`
+ * does not expose the OS-assigned ephemeral port ergonomically, and the port is
+ * load-bearing here: it becomes the `redirect_uri` that Google echoes back and
+ * that the token exchange must repeat byte-for-byte.
+ *
+ * Behavioral contract (internal/oauth/oauth.go, handler registered on "/"):
+ *
+ * 1. `state` mismatch -> HTTP 400, plain text, and the flow KEEPS WAITING.
+ * This is the robustness-critical branch: a favicon probe from the browser
+ * or a port scanner hitting the loopback port must not be able to abort a
+ * login that is still in flight.
+ * 2. `error` param present -> 400 and the flow resolves with that OAuth error.
+ * 3. `code` missing/blank -> 400 and the flow resolves with an operational error.
+ * 4. otherwise -> 200 text/html and the flow resolves with the code.
+ *
+ * Go's result channel is buffered size 1 with non-blocking sends, so only the
+ * first resolution wins and later callbacks are ignored. `Deferred.doneUnsafe`
+ * has exactly that semantics (it returns `false` when already completed).
+ *
+ * The 400 bodies reproduce Go's `http.Error` byte-for-byte: the message plus a
+ * trailing newline, `Content-Type: text/plain; charset=utf-8`, and
+ * `X-Content-Type-Options: nosniff`. Verified against a real `httptest` server.
+ */
+
+import { Deferred, Effect, type Scope } from "effect"
+import { OAuthError, OperationalError } from "../domain/errors.ts"
+
+/** Go `http.Error` bodies — the trailing newline is part of the response. */
+export const STATE_MISMATCH_BODY = "OAuth state did not match. You can close this window.\n"
+export const NOT_GRANTED_BODY = "Authorization was not granted. You can close this window.\n"
+export const MISSING_CODE_BODY =
+ "The OAuth callback did not include a code. You can close this window.\n"
+
+/** The success page, byte-exact. */
+export const SUCCESS_BODY =
+ "oytc authorized " +
+ "Authorization complete. You can close this window and return to oytc.
"
+
+export const MISSING_CODE_MESSAGE = "OAuth callback did not include an authorization code"
+
+export interface LoopbackServer {
+ readonly port: number
+ /** `http://127.0.0.1:` — NO trailing slash and NO path, exactly as Go builds it. */
+ readonly redirectUri: string
+ /** Resolves once, with the authorization code or the failure that ended the flow. */
+ readonly awaitCode: Effect.Effect
+}
+
+const goHttpError = (body: string): Response =>
+ new Response(body, {
+ status: 400,
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "X-Content-Type-Options": "nosniff"
+ }
+ })
+
+const describe = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause))
+
+/**
+ * Starts the loopback listener on an ephemeral port and stops it when the
+ * surrounding scope closes. `state` is the CSRF value the callback must echo.
+ */
+export const acquireLoopbackServer = (
+ state: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const deferred = Deferred.makeUnsafe()
+
+ const handle = (request: Request): Response => {
+ // The handler answers on EVERY path, matching Go's mux pattern "/".
+ const query = new URL(request.url).searchParams
+
+ if (query.get("state") !== state) {
+ return goHttpError(STATE_MISMATCH_BODY)
+ }
+
+ const errorCode = query.get("error") ?? ""
+ if (errorCode !== "") {
+ Deferred.doneUnsafe(
+ deferred,
+ Effect.fail(
+ new OAuthError({
+ httpStatus: 0,
+ code: errorCode,
+ description: query.get("error_description") ?? ""
+ })
+ )
+ )
+ return goHttpError(NOT_GRANTED_BODY)
+ }
+
+ const code = (query.get("code") ?? "").trim()
+ if (code === "") {
+ Deferred.doneUnsafe(
+ deferred,
+ Effect.fail(new OperationalError({ message: MISSING_CODE_MESSAGE }))
+ )
+ return goHttpError(MISSING_CODE_BODY)
+ }
+
+ Deferred.doneUnsafe(deferred, Effect.succeed(code))
+ return new Response(SUCCESS_BODY, {
+ status: 200,
+ headers: { "Content-Type": "text/html; charset=utf-8" }
+ })
+ }
+
+ const server = yield* Effect.acquireRelease(
+ Effect.try({
+ try: () =>
+ Bun.serve({
+ port: 0,
+ hostname: "127.0.0.1",
+ fetch: handle
+ }),
+ catch: (cause) =>
+ new OperationalError({
+ message: `start OAuth callback listener: ${describe(cause)}`,
+ cause
+ })
+ }),
+ (running) => Effect.promise(async () => await running.stop())
+ )
+
+ // `Bun.serve` types `port` as optional, but a listening TCP server always
+ // has one; the OS assigns it because we asked for port 0.
+ const port = server.port ?? 0
+ return {
+ port,
+ redirectUri: `http://127.0.0.1:${port}`,
+ awaitCode: Deferred.await(deferred)
+ }
+ })
diff --git a/src/impl/platformMatrix.test.ts b/src/impl/platformMatrix.test.ts
new file mode 100644
index 0000000..62de5f8
--- /dev/null
+++ b/src/impl/platformMatrix.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, test } from "bun:test"
+import {
+ archiveExtension,
+ assertBuildablePlatform,
+ assetName,
+ binaryName,
+ bunTarget,
+ bunTargets,
+ hostPlatform,
+ isSupportedPlatform,
+ SUPPORTED_PLATFORMS,
+ UnsupportedPlatformError
+} from "./platformMatrix.ts"
+
+/** Port of Go's `TestAssetName` — unchanged, byte for byte. */
+describe("TestAssetName", () => {
+ test("linux/arm64 is a tar.gz", () => {
+ expect(assetName("v0.1.0", "linux", "arm64")).toBe("oytc_v0.1.0_linux_arm64.tar.gz")
+ })
+
+ test("windows/amd64 is a zip", () => {
+ expect(assetName("v0.1.0", "windows", "amd64")).toBe("oytc_v0.1.0_windows_amd64.zip")
+ })
+})
+
+describe("assetName", () => {
+ test("windows/arm64 hard-fails — the platform was dropped in the TS port", () => {
+ expect(() => assetName("v0.1.0", "windows", "arm64")).toThrow(UnsupportedPlatformError)
+ expect(() => assetName("v0.1.0", "windows", "arm64")).toThrow(/windows\/arm64/)
+ expect(() => assetName("v0.1.0", "windows", "arm64")).toThrow(/amd64 build instead/)
+ })
+
+ test("the tag is interpolated verbatim, leading v included", () => {
+ expect(assetName("v1.2.3-rc.1", "darwin", "arm64")).toBe(
+ "oytc_v1.2.3-rc.1_darwin_arm64.tar.gz"
+ )
+ })
+
+ test("an unknown-but-not-dropped pair still formats, so the missing-asset error wins", () => {
+ // Matches Go: `TestUpdateMissingAssetForPlatform` sets GOARCH=riscv64 and
+ // expects the release lookup to fail, not the name computation.
+ expect(assetName("v0.2.0", "linux", "riscv64")).toBe("oytc_v0.2.0_linux_riscv64.tar.gz")
+ })
+
+ test("every supported platform produces the documented name", () => {
+ expect(SUPPORTED_PLATFORMS.map((p) => assetName("v0.1.0", p.goos, p.goarch))).toEqual([
+ "oytc_v0.1.0_linux_amd64.tar.gz",
+ "oytc_v0.1.0_linux_arm64.tar.gz",
+ "oytc_v0.1.0_darwin_amd64.tar.gz",
+ "oytc_v0.1.0_darwin_arm64.tar.gz",
+ "oytc_v0.1.0_windows_amd64.zip"
+ ])
+ })
+})
+
+describe("archiveExtension / binaryName", () => {
+ test("windows gets zip and oytc.exe", () => {
+ expect(archiveExtension("windows")).toBe("zip")
+ expect(binaryName("windows")).toBe("oytc.exe")
+ })
+
+ test("everything else gets tar.gz and oytc", () => {
+ for (const goos of ["linux", "darwin", "freebsd"]) {
+ expect(archiveExtension(goos)).toBe("tar.gz")
+ expect(binaryName(goos)).toBe("oytc")
+ }
+ })
+})
+
+describe("hostPlatform — process.platform/arch -> goos/goarch", () => {
+ test("maps the five published hosts", () => {
+ expect(hostPlatform("linux", "x64")).toEqual({ goos: "linux", goarch: "amd64" })
+ expect(hostPlatform("linux", "arm64")).toEqual({ goos: "linux", goarch: "arm64" })
+ expect(hostPlatform("darwin", "x64")).toEqual({ goos: "darwin", goarch: "amd64" })
+ expect(hostPlatform("darwin", "arm64")).toEqual({ goos: "darwin", goarch: "arm64" })
+ expect(hostPlatform("win32", "x64")).toEqual({ goos: "windows", goarch: "amd64" })
+ })
+
+ test("win32 + arm64 hard-fails rather than requesting a nonexistent asset", () => {
+ expect(() => hostPlatform("win32", "arm64")).toThrow(UnsupportedPlatformError)
+ expect(() => hostPlatform("win32", "arm64")).toThrow(/windows\/arm64/)
+ })
+
+ test("an unknown platform or arch names the host, not an asset", () => {
+ expect(() => hostPlatform("aix", "x64")).toThrow(/aix\/x64/)
+ expect(() => hostPlatform("linux", "riscv64")).toThrow(/linux\/riscv64/)
+ expect(() => hostPlatform("linux", "ia32")).toThrow(UnsupportedPlatformError)
+ })
+
+ test("the error carries the goos/goarch it could resolve", () => {
+ try {
+ hostPlatform("win32", "arm64")
+ throw new Error("expected a throw")
+ } catch (error) {
+ expect(error).toBeInstanceOf(UnsupportedPlatformError)
+ expect((error as UnsupportedPlatformError).goos).toBe("windows")
+ expect((error as UnsupportedPlatformError).goarch).toBe("arm64")
+ }
+ })
+})
+
+describe("assertBuildablePlatform", () => {
+ test("passes for every supported platform", () => {
+ for (const p of SUPPORTED_PLATFORMS) {
+ expect(() => assertBuildablePlatform(p.goos, p.goarch)).not.toThrow()
+ }
+ })
+
+ test("throws only for the dropped pair", () => {
+ expect(() => assertBuildablePlatform("windows", "arm64")).toThrow()
+ expect(() => assertBuildablePlatform("linux", "riscv64")).not.toThrow()
+ })
+})
+
+describe("isSupportedPlatform", () => {
+ test("windows/arm64 is not supported; windows/amd64 is", () => {
+ expect(isSupportedPlatform("windows", "arm64")).toBe(false)
+ expect(isSupportedPlatform("windows", "amd64")).toBe(true)
+ })
+})
+
+/**
+ * Mapping (b): bun tokens are BUILD-time only. If one of these ever leaks into
+ * `assetName` the release stops being self-updatable, so they are asserted to
+ * be different strings from the goarch tokens.
+ */
+describe("bunTarget — goos/goarch -> bun --target", () => {
+ test("the five compile targets", () => {
+ expect(bunTargets()).toEqual([
+ "bun-linux-x64",
+ "bun-linux-arm64",
+ "bun-darwin-x64",
+ "bun-darwin-arm64",
+ "bun-windows-x64"
+ ])
+ })
+
+ test("amd64 becomes x64 for bun but stays amd64 in asset names", () => {
+ expect(bunTarget("linux", "amd64")).toBe("bun-linux-x64")
+ expect(assetName("v1.0.0", "linux", "amd64")).toContain("_amd64.")
+ expect(assetName("v1.0.0", "linux", "amd64")).not.toContain("x64")
+ })
+
+ test("arm64 is spelled the same in both mappings", () => {
+ expect(bunTarget("darwin", "arm64")).toBe("bun-darwin-arm64")
+ expect(assetName("v1.0.0", "darwin", "arm64")).toContain("_arm64.")
+ })
+
+ test("there is no bun-windows-arm64 target in the matrix", () => {
+ expect(bunTargets()).not.toContain("bun-windows-arm64")
+ })
+})
diff --git a/src/impl/platformMatrix.ts b/src/impl/platformMatrix.ts
index ca14f9a..7ab8d3f 100644
--- a/src/impl/platformMatrix.ts
+++ b/src/impl/platformMatrix.ts
@@ -1,2 +1,162 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * THE PLATFORM MATRIX. **[MATRIX]**
+ *
+ * Two mappings live here and must never be confused:
+ *
+ * (a) `process.platform` / `process.arch` -> Go's `GOOS` / `GOARCH` tokens.
+ * These are the tokens baked into RELEASE ASSET NAMES. They are frozen:
+ * every oytc already installed in the world computes its own update
+ * asset name from them, so renaming `amd64` to `x64` would strand every
+ * existing client. The Go binary is gone; the names it minted are not.
+ *
+ * (b) Go's `GOOS` / `GOARCH` tokens -> `bun build --compile --target` tokens
+ * (`bun-linux-x64`, ...). A BUILD-TIME concern only; a bun token must
+ * never reach an asset name.
+ *
+ * `scripts/package.sh`, `site/install.sh`, `site/install.ps1` and the CI
+ * workflows hardcode the same naming; `.depot/workflows/ci.yml` greps this
+ * file for the literal `oytc_${tag}_${goos}_${goarch}` template in
+ * `assetName` below to prove the three sources still agree. Do not reformat
+ * that template literal.
+ */
+
+/** Go `GOOS` tokens the project publishes for. */
+export type Goos = "linux" | "darwin" | "windows"
+
+/** Go `GOARCH` tokens the project publishes for. */
+export type Goarch = "amd64" | "arm64"
+
+export interface Platform {
+ readonly goos: Goos
+ readonly goarch: Goarch
+}
+
+/** (a) `process.platform` -> `GOOS`. */
+const GOOS_BY_NODE_PLATFORM: Readonly> = {
+ linux: "linux",
+ darwin: "darwin",
+ win32: "windows"
+}
+
+/** (a) `process.arch` -> `GOARCH`. */
+const GOARCH_BY_NODE_ARCH: Readonly> = {
+ x64: "amd64",
+ arm64: "arm64"
+}
+
+/** (b) `GOOS` -> the OS token in a `bun --target` triple. */
+const BUN_OS_BY_GOOS: Readonly> = {
+ linux: "linux",
+ darwin: "darwin",
+ windows: "windows"
+}
+
+/** (b) `GOARCH` -> the arch token in a `bun --target` triple. */
+const BUN_ARCH_BY_GOARCH: Readonly> = {
+ amd64: "x64",
+ arm64: "arm64"
+}
+
+/**
+ * The five platforms the project builds and publishes.
+ *
+ * `windows/arm64` was published by the Go implementation and is deliberately
+ * NOT here: `bun build --compile` has no `bun-windows-arm64` target. ARM64
+ * Windows runs the `windows/amd64` build under emulation instead.
+ */
+export const SUPPORTED_PLATFORMS: ReadonlyArray = [
+ { goos: "linux", goarch: "amd64" },
+ { goos: "linux", goarch: "arm64" },
+ { goos: "darwin", goarch: "amd64" },
+ { goos: "darwin", goarch: "arm64" },
+ { goos: "windows", goarch: "amd64" }
+]
+
+/**
+ * Platform pairs the Go implementation published but this one dropped.
+ *
+ * These hard-fail with a specific remedy rather than the generic
+ * "release has no asset for this platform", because an already-installed
+ * client WILL ask for one and deserves to be told why it vanished. Any other
+ * unknown pair (say `linux/riscv64`) is formatted verbatim into an asset name
+ * and fails later with the ordinary missing-asset error, exactly as the Go
+ * updater did — `TestUpdateMissingAssetForPlatform` depends on that.
+ */
+const DROPPED: ReadonlyArray = [{ goos: "windows", goarch: "arm64" }]
+
+const isDropped = (goos: string, goarch: string): boolean =>
+ DROPPED.some((p) => p.goos === goos && p.goarch === goarch)
+
+export class UnsupportedPlatformError extends Error {
+ override readonly name = "UnsupportedPlatformError"
+ readonly goos: string
+ readonly goarch: string
+ constructor(goos: string, goarch: string, message: string) {
+ super(message)
+ this.goos = goos
+ this.goarch = goarch
+ }
+}
+
+const droppedMessage = (goos: string, goarch: string): string =>
+ `oytc is not published for ${goos}/${goarch}; install the ${goos}/amd64 build instead, which runs under emulation on ARM64 Windows`
+
+/**
+ * Throws when the pair is one this project deliberately stopped publishing.
+ * Call it early so the failure precedes any network traffic.
+ */
+export const assertBuildablePlatform = (goos: string, goarch: string): void => {
+ if (isDropped(goos, goarch)) {
+ throw new UnsupportedPlatformError(goos, goarch, droppedMessage(goos, goarch))
+ }
+}
+
+export const isSupportedPlatform = (goos: string, goarch: string): boolean =>
+ SUPPORTED_PLATFORMS.some((p) => p.goos === goos && p.goarch === goarch)
+
+/** `zip` on Windows, `tar.gz` everywhere else. */
+export const archiveExtension = (goos: string): string => (goos === "windows" ? "zip" : "tar.gz")
+
+/** The single file at the archive root. */
+export const binaryName = (goos: string): string => (goos === "windows" ? "oytc.exe" : "oytc")
+
+/**
+ * The release asset filename for a tag and platform,
+ * e.g. `oytc_v0.1.0_linux_amd64.tar.gz`. The tag INCLUDES its leading `v`.
+ *
+ * Throws `UnsupportedPlatformError` for a dropped platform (`windows/arm64`).
+ */
+export const assetName = (tag: string, goos: string, goarch: string): string => {
+ assertBuildablePlatform(goos, goarch)
+ const ext = archiveExtension(goos)
+ return `oytc_${tag}_${goos}_${goarch}.${ext}`
+}
+
+/** The `bun build --compile --target=` token for a published platform. */
+export const bunTarget = (goos: Goos, goarch: Goarch): string =>
+ `bun-${BUN_OS_BY_GOOS[goos]}-${BUN_ARCH_BY_GOARCH[goarch]}`
+
+/** Every `bun --target` token the release build must produce, in matrix order. */
+export const bunTargets = (): ReadonlyArray =>
+ SUPPORTED_PLATFORMS.map((p) => bunTarget(p.goos, p.goarch))
+
+/**
+ * Map the running process onto `GOOS`/`GOARCH`.
+ *
+ * Throws `UnsupportedPlatformError` when the host is not a platform oytc is
+ * published for, so the failure names the host rather than an asset that was
+ * never going to exist.
+ */
+export const hostPlatform = (platform: string, arch: string): Platform => {
+ const goos = GOOS_BY_NODE_PLATFORM[platform]
+ const goarch = GOARCH_BY_NODE_ARCH[arch]
+ if (goos === undefined || goarch === undefined) {
+ throw new UnsupportedPlatformError(
+ goos ?? platform,
+ goarch ?? arch,
+ `oytc does not publish a build for ${platform}/${arch}`
+ )
+ }
+ assertBuildablePlatform(goos, goarch)
+ return { goos, goarch }
+}
diff --git a/src/impl/prompts.test.ts b/src/impl/prompts.test.ts
new file mode 100644
index 0000000..c8e08b4
--- /dev/null
+++ b/src/impl/prompts.test.ts
@@ -0,0 +1,320 @@
+/**
+ * Tests for the shared stdin reader.
+ *
+ * Go has no dedicated test for `readSecret` — it is covered indirectly through
+ * `internal/cli/app_test.go`, which swaps `app.ReadSecret` for a stub. These
+ * tests therefore target the three properties the port must not regress:
+ * one shared reader, no TTY requirement, prompts on stderr.
+ */
+
+import { describe, expect, test } from "bun:test"
+import { Cause, Effect, Exit, Redacted } from "effect"
+import { makePromptsWith, testPromptIO, type PromptIO } from "./prompts.ts"
+import { OperationalError } from "../domain/errors.ts"
+
+const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect)
+
+const exitOf = (effect: Effect.Effect): Promise> =>
+ Effect.runPromise(Effect.exit(effect))
+
+const messageOf = (exit: Exit.Exit): string => {
+ if (exit._tag !== "Failure") return ""
+ const error = Cause.findErrorOption(exit.cause)
+ return error._tag === "Some" && error.value instanceof OperationalError ? error.value.message : ""
+}
+
+/** Control bytes the raw-mode loop has to interpret itself. */
+const DEL = String.fromCharCode(0x7f)
+const BACKSPACE = String.fromCharCode(0x08)
+const CTRL_C = String.fromCharCode(0x03)
+const CR = String.fromCharCode(0x0d)
+
+describe("shared stdin reader", () => {
+ test("consecutive prompts see consecutive lines from one piped stream", async () => {
+ // The whole reason Go keeps one bufio.Reader: reading the client ID pulls
+ // the secret's bytes into the buffer too. A fresh reader would lose them.
+ const io = testPromptIO("client-id\nclient-secret\n")
+ const prompts = makePromptsWith(io)
+
+ const id = await run(prompts.readLine("OAuth client ID: "))
+ const secret = await run(prompts.readSecret("OAuth client secret: "))
+
+ expect(id).toBe("client-id")
+ expect(Redacted.value(secret)).toBe("client-secret")
+ })
+
+ test("three prompts in a row stay in order", async () => {
+ const io = testPromptIO("one\ntwo\nthree\n")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("a: "))).toBe("one")
+ expect(Redacted.value(await run(prompts.readSecret("b: ")))).toBe("two")
+ expect(await run(prompts.readLine("c: "))).toBe("three")
+ })
+
+ test("a one-byte-at-a-time descriptor produces the same lines", async () => {
+ const io = testPromptIO("client-id\nclient-secret\n", { chunkSize: 1 })
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("id: "))).toBe("client-id")
+ expect(Redacted.value(await run(prompts.readSecret("secret: ")))).toBe("client-secret")
+ })
+
+ test("CRLF input has its terminator stripped", async () => {
+ const io = testPromptIO(`client-id${CR}\nsecret${CR}\n`)
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("id: "))).toBe("client-id")
+ expect(Redacted.value(await run(prompts.readSecret("s: ")))).toBe("secret")
+ })
+
+ test("a run of CR/LF is stripped, but an interior CR is kept", async () => {
+ // Verified against Go: strings.TrimRight(line, "\r\n") on "abc\r\r\n"
+ // yields "abc", and "a\rb\n" yields "a\rb".
+ const io = testPromptIO(`abc${CR}${CR}\na${CR}b\n`)
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("x: "))).toBe("abc")
+ expect(await run(prompts.readLine("y: "))).toBe(`a${CR}b`)
+ })
+
+ test("UTF-8 survives a chunk boundary mid-codepoint", async () => {
+ // Split one byte at a time, so the decoder sees partial sequences.
+ const io = testPromptIO("héllo→\n", { chunkSize: 1 })
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("x: "))).toBe("héllo→")
+ })
+})
+
+describe("readSecret", () => {
+ test("works when stdin is a pipe — no TTY required", async () => {
+ // `secret-manager | oytc login` is a documented workflow.
+ const io = testPromptIO("piped-api-key\n", { isInputTTY: false })
+ const prompts = makePromptsWith(io)
+ const secret = await run(prompts.readSecret("YouTube Data API key: "))
+ expect(Redacted.value(secret)).toBe("piped-api-key")
+ })
+
+ test("EOF without a trailing newline is not an error", async () => {
+ // Go: `if err != nil && !errors.Is(err, io.EOF) { return "", err }`.
+ const io = testPromptIO("no-newline-at-end")
+ const prompts = makePromptsWith(io)
+ expect(Redacted.value(await run(prompts.readSecret("key: ")))).toBe("no-newline-at-end")
+ })
+
+ test("an empty line at EOF yields an empty secret, not a failure", async () => {
+ // The caller turns "" into the usage error "API key cannot be empty".
+ const io = testPromptIO("")
+ const prompts = makePromptsWith(io)
+ expect(Redacted.value(await run(prompts.readSecret("key: ")))).toBe("")
+ })
+
+ test("the prompt goes to stderr and a newline follows the read", async () => {
+ const io = testPromptIO("s3cret\n")
+ const prompts = makePromptsWith(io)
+ await run(prompts.readSecret("YouTube Data API key: "))
+ expect(io.errorOutput()).toBe("YouTube Data API key: \n")
+ })
+
+ test("on a TTY, echo is suppressed and restored around the read", async () => {
+ const io = testPromptIO("hunter2\n", { isInputTTY: true, chunkSize: 1 })
+ const prompts = makePromptsWith(io)
+ expect(Redacted.value(await run(prompts.readSecret("pw: ")))).toBe("hunter2")
+ expect(io.rawModeCalls()).toEqual([true, false])
+ })
+
+ test("raw mode handles backspace, which the line driver would normally do", async () => {
+ // Raw mode clears ICANON, so DEL and BS become our job.
+ const io = testPromptIO(`abc${DEL}${BACKSPACE}d\n`, { isInputTTY: true, chunkSize: 1 })
+ const prompts = makePromptsWith(io)
+ expect(Redacted.value(await run(prompts.readSecret("pw: ")))).toBe("ad")
+ })
+
+ test("raw mode ends the line on a bare CR", async () => {
+ const io = testPromptIO(`hunter2${CR}rest\n`, { isInputTTY: true, chunkSize: 1 })
+ const prompts = makePromptsWith(io)
+ expect(Redacted.value(await run(prompts.readSecret("pw: ")))).toBe("hunter2")
+ })
+
+ test("EOF mid-secret on a TTY returns what was typed, with no error", async () => {
+ const io = testPromptIO("partial", { isInputTTY: true, chunkSize: 1 })
+ const prompts = makePromptsWith(io)
+ expect(Redacted.value(await run(prompts.readSecret("pw: ")))).toBe("partial")
+ expect(io.rawModeCalls()).toEqual([true, false])
+ })
+
+ test("Ctrl-C in raw mode interrupts rather than returning a partial secret", async () => {
+ // Raw mode also clears ISIG, which Go's termios tweak deliberately kept,
+ // so 0x03 has to be turned back into an interrupt by hand (exit 130).
+ const io = testPromptIO(`part${CTRL_C}ial\n`, { isInputTTY: true, chunkSize: 1 })
+ const prompts = makePromptsWith(io)
+ const exit = await exitOf(prompts.readSecret("pw: "))
+ expect(exit._tag).toBe("Failure")
+ if (exit._tag === "Failure") expect(Cause.hasInterrupts(exit.cause)).toBe(true)
+ // Raw mode is restored even on the way out.
+ expect(io.rawModeCalls()).toEqual([true, false])
+ })
+
+ test("a partly-buffered CRLF line does not smuggle a CR into the secret", async () => {
+ // The previous prompt's read pulled "sec\r" in but not the LF, so the raw
+ // loop starts with buffered bytes. Those must go through the same CR/LF
+ // rules as typed bytes — Go's pipe path trims with TrimRight and its TTY
+ // path drops CR in readPasswordLine, so neither yields "sec\r".
+ const io = testPromptIO(`id\nsec${CR}`, { isInputTTY: true })
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("id: "))).toBe("id")
+ expect(Redacted.value(await run(prompts.readSecret("pw: ")))).toBe("sec")
+ })
+
+ test("buffered bytes before a typed tail still edit correctly in raw mode", async () => {
+ const io = testPromptIO(`id\nabX${BACKSPACE}c\n`, { isInputTTY: true, chunkSize: 5 })
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("id: "))).toBe("id")
+ expect(Redacted.value(await run(prompts.readSecret("pw: ")))).toBe("abc")
+ })
+
+ test("a TTY still yields already-buffered bytes without entering raw mode", async () => {
+ // The previous prompt's read pulled this line in; nothing left to hide.
+ const io = testPromptIO("id\nsecret\n", { isInputTTY: true })
+ const prompts = makePromptsWith(io)
+ await run(prompts.readLine("id: "))
+ expect(Redacted.value(await run(prompts.readSecret("pw: ")))).toBe("secret")
+ expect(io.rawModeCalls()).toEqual([])
+ })
+
+ test("the secret is Redacted, so an accidental log prints ", async () => {
+ const io = testPromptIO("AIzaTOPSECRET\n")
+ const prompts = makePromptsWith(io)
+ const secret = await run(prompts.readSecret("key: "))
+ expect(String(secret)).not.toContain("AIzaTOPSECRET")
+ expect(`${secret}`).toContain("redacted")
+ })
+})
+
+describe("readLine", () => {
+ test("writes the prompt to stderr and echoes nothing itself", async () => {
+ const io = testPromptIO("value\n")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("OAuth client ID: "))).toBe("value")
+ expect(io.errorOutput()).toBe("OAuth client ID: ")
+ })
+
+ test("a partial read at EOF is tolerated when it produced a value", async () => {
+ // Go: `if err != nil && strings.TrimSpace(clientID) == "" { return err }`.
+ const io = testPromptIO("trailing-value-no-newline")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("id: "))).toBe("trailing-value-no-newline")
+ })
+
+ test("EOF with nothing read fails", async () => {
+ const io = testPromptIO("")
+ const prompts = makePromptsWith(io)
+ const exit = await exitOf(prompts.readLine("id: "))
+ expect(exit._tag).toBe("Failure")
+ expect(messageOf(exit)).toBe("EOF")
+ })
+
+ test("EOF with only whitespace read fails — Go trims before the check", async () => {
+ const io = testPromptIO(" ")
+ const prompts = makePromptsWith(io)
+ expect(messageOf(await exitOf(prompts.readLine("id: ")))).toBe("EOF")
+ })
+
+ test("surrounding whitespace is left to the caller to trim", async () => {
+ // Go trimmed at the call site, not in the reader.
+ const io = testPromptIO(" spaced \n")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("id: "))).toBe(" spaced ")
+ })
+
+ test("a blank line before EOF is a value, not an EOF failure", async () => {
+ const io = testPromptIO("\nsecond\n")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.readLine("id: "))).toBe("")
+ expect(await run(prompts.readLine("id: "))).toBe("second")
+ })
+})
+
+describe("confirm", () => {
+ test.each([
+ ["y\n", true],
+ ["yes\n", true],
+ ["Y\n", true],
+ ["YES\n", true],
+ [" yes \n", true],
+ ["n\n", false],
+ ["no\n", false],
+ ["nope\n", false],
+ ["\n", false],
+ ["yep\n", false],
+ ["ye\n", false]
+ ])("%j -> %s", async (input, expected) => {
+ const io = testPromptIO(input)
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.confirm("Continue? [y/N] "))).toBe(expected)
+ })
+
+ test("writes the block to stderr, then a newline after the read", async () => {
+ const block =
+ "Install the bundled oytc agent skill?\nDestination: /tmp/oytc\n" +
+ "Permission requested: create this directory and write SKILL.md plus references.\n" +
+ "Continue? [y/N] "
+ const io = testPromptIO("y\n")
+ const prompts = makePromptsWith(io)
+ await run(prompts.confirm(block))
+ expect(io.errorOutput()).toBe(`${block}\n`)
+ })
+
+ test("a bare EOF is a read failure, matching Go's zero-bytes check", async () => {
+ const io = testPromptIO("")
+ const prompts = makePromptsWith(io)
+ const exit = await exitOf(prompts.confirm("Continue? [y/N] "))
+ expect(exit._tag).toBe("Failure")
+ expect(messageOf(exit)).toBe("EOF")
+ // Go returned before printing the trailing newline on this path.
+ expect(io.errorOutput()).toBe("Continue? [y/N] ")
+ })
+
+ test("a lone CR at EOF cancels rather than failing — Go checks the RAW length", async () => {
+ // skills.go gates on `len(answer) == 0`, i.e. on the untrimmed string.
+ // Verified against Go: input "\r" -> cancelled (answer="\r", err=EOF).
+ // Checking the trimmed line instead would turn this into
+ // `read confirmation: EOF` and change the exit code.
+ const io = testPromptIO(CR)
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.confirm("Continue? [y/N] "))).toBe(false)
+ })
+
+ test("whitespace-only input at EOF cancels, it is not a read failure", async () => {
+ // Go: input " " -> cancelled (answer=" ", err=EOF).
+ const io = testPromptIO(" ")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.confirm("Continue? [y/N] "))).toBe(false)
+ })
+
+ test("'yes' with no trailing newline still confirms", async () => {
+ // Go tolerated the EOF error because bytes were read.
+ const io = testPromptIO("yes")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.confirm("Continue? [y/N] "))).toBe(true)
+ })
+
+ test("a declined confirmation leaves the rest of stdin for the next prompt", async () => {
+ const io = testPromptIO("no\nleftover\n")
+ const prompts = makePromptsWith(io)
+ expect(await run(prompts.confirm("Continue? [y/N] "))).toBe(false)
+ expect(await run(prompts.readLine("next: "))).toBe("leftover")
+ })
+})
+
+describe("host failures", () => {
+ test("a descriptor error surfaces as an OperationalError, not a defect", async () => {
+ const io: PromptIO = {
+ read: () => {
+ throw new Error("EIO: i/o error")
+ },
+ writeError: () => {},
+ isInputTTY: false
+ }
+ const prompts = makePromptsWith(io)
+ expect(messageOf(await exitOf(prompts.readLine("id: ")))).toBe("EIO: i/o error")
+ expect(messageOf(await exitOf(prompts.readSecret("pw: ")))).toBe("EIO: i/o error")
+ expect(messageOf(await exitOf(prompts.confirm("Continue? [y/N] ")))).toBe("EIO: i/o error")
+ })
+})
diff --git a/src/impl/prompts.ts b/src/impl/prompts.ts
index ca14f9a..e8700db 100644
--- a/src/impl/prompts.ts
+++ b/src/impl/prompts.ts
@@ -1,2 +1,373 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * `Prompts` — the port of `App.readSecret` / `App.stdinReader` in
+ * `internal/cli/app.go`, plus the confirmation read in `internal/cli/skills.go`.
+ *
+ * Three properties are load-bearing and each one exists because getting it
+ * wrong breaks a documented workflow:
+ *
+ * 1. **ONE shared stdin reader, for every prompt.** `login --oauth` asks for a
+ * client ID and then a client secret. A buffered read for the first prompt
+ * routinely pulls the second line into its buffer too; a fresh reader for
+ * the second prompt would never see those bytes. Go's comment on
+ * `stdinReader()` says exactly this. The module keeps one byte buffer and
+ * every prompt drains it before touching the file descriptor.
+ *
+ * 2. **`readSecret` must not require a TTY.** `secret-manager | oytc login` is
+ * a documented workflow. On a TTY the terminal is put in raw mode so the
+ * secret is not echoed; on a pipe it is an ordinary line read, and EOF is
+ * NOT an error there (Go: `if err != nil && !errors.Is(err, io.EOF)`).
+ * Note the asymmetry with `readLine`, where a read error with an empty
+ * result IS fatal — that difference is Go's, and it is reproduced.
+ *
+ * 3. **Prompts are written to stderr.** stdout stays clean so
+ * `oytc ... --format json | jq` keeps working while a prompt is on screen.
+ *
+ * Ownership of the surrounding whitespace, so callers do not double up:
+ * - `readLine` writes the prompt and nothing else (the terminal echoes the
+ * user's Enter; a pipe produces no echo, and Go printed nothing either).
+ * - `readSecret` writes the prompt, then a newline after the read, because
+ * echo was suppressed and nothing else would end the line.
+ * - `confirm` writes the block, then a newline after a *successful* read —
+ * on a read failure Go returned before printing it.
+ *
+ * Error messages carry only the underlying reason ("EOF"), never a prefix.
+ * The command layer adds `read OAuth client ID: `, `read API key: `,
+ * `read confirmation: ` etc., matching Go's `fmt.Errorf("...: %w", err)`.
+ */
+
+import * as fs from "node:fs"
+import { Effect, Layer, Redacted } from "effect"
+import { OperationalError } from "../domain/errors.ts"
+import { Prompts, type PromptsShape } from "../services/index.ts"
+
+const LF = 0x0a
+const CR = 0x0d
+const ETX = 0x03 // Ctrl-C
+const BACKSPACE = 0x08
+const DEL = 0x7f
+
+/** Raised from the raw-mode loop when the user presses Ctrl-C. */
+const INTERRUPTED = Symbol.for("oytc/prompts/interrupted")
+
+/**
+ * The host seam. Production wires this to fds 0 and 2; tests substitute a
+ * scripted buffer so the shared-reader behaviour can be asserted without a
+ * terminal.
+ */
+export interface PromptIO {
+ /** Read up to `size` bytes. An empty result means EOF. Blocking. */
+ readonly read: (size: number) => Uint8Array
+ /** Write to stderr. */
+ readonly writeError: (text: string) => void
+ readonly isInputTTY: boolean
+ /** Present only when the input can suppress echo. */
+ readonly setRawMode?: ((enabled: boolean) => void) | undefined
+}
+
+const CHUNK = 4096
+
+const hostIO = (): PromptIO => {
+ const stdin = process.stdin as unknown as {
+ isTTY?: boolean
+ setRawMode?: (enabled: boolean) => void
+ }
+ const isInputTTY = stdin.isTTY === true
+ return {
+ read: (size) => {
+ const buffer = new Uint8Array(size)
+ for (;;) {
+ try {
+ const read = fs.readSync(0, buffer, 0, size, null)
+ return buffer.subarray(0, read)
+ } catch (error) {
+ const code = (error as { code?: string } | null)?.code
+ // A non-blocking tty has nothing ready yet: wait and ask again.
+ if (code === "EAGAIN") {
+ Bun.sleepSync(5)
+ continue
+ }
+ // Both spellings mean "the descriptor is done".
+ if (code === "EOF" || code === "ENXIO") return new Uint8Array(0)
+ throw error
+ }
+ }
+ },
+ writeError: (text) => {
+ fs.writeSync(2, text)
+ },
+ isInputTTY,
+ setRawMode:
+ isInputTTY && typeof stdin.setRawMode === "function"
+ ? (enabled: boolean) => stdin.setRawMode!(enabled)
+ : undefined
+ }
+}
+
+/** What a line read produced. `endedAtEof` mirrors bufio's trailing `io.EOF`. */
+interface LineRead {
+ /** The line without its terminator. */
+ readonly line: string
+ /**
+ * True when input ended before a `\n` was seen. Go's `bufio.ReadString`
+ * returns `io.EOF` in exactly this case, including when it read partial
+ * data first, so callers reproduce Go's "tolerate the error if we still got
+ * something" logic off this flag.
+ */
+ readonly endedAtEof: boolean
+ /**
+ * Byte count of what `ReadString` returned, terminator INCLUDED.
+ *
+ * `skills.go` gates on `len(answer) == 0`, i.e. on the raw string — so a
+ * lone `"\r"` at EOF is a *cancel* there, not a read failure, even though the
+ * trimmed line is empty. Testing `line.length` instead would turn that into
+ * `read confirmation: EOF`. Verified against Go.
+ */
+ readonly rawLength: number
+}
+
+const decoder = new TextDecoder()
+
+/**
+ * The one buffered reader. Bytes pulled from the descriptor for one prompt
+ * stay here and are handed to the next prompt, which is the entire point.
+ */
+class SharedStdin {
+ private pending: Uint8Array = new Uint8Array(0)
+ private exhausted = false
+
+ constructor(private readonly io: PromptIO) {}
+
+ private take(count: number): Uint8Array {
+ const taken = this.pending.subarray(0, count)
+ this.pending = this.pending.slice(count)
+ return taken
+ }
+
+ private append(chunk: Uint8Array): void {
+ const merged = new Uint8Array(this.pending.length + chunk.length)
+ merged.set(this.pending, 0)
+ merged.set(chunk, this.pending.length)
+ this.pending = merged
+ }
+
+ /** Whether a complete line is already buffered — no descriptor read needed. */
+ private bufferedLineEnd(): number {
+ return this.pending.indexOf(LF)
+ }
+
+ /** Go's `bufio.Reader.ReadString('\n')`, minus the delimiter. */
+ readLine(): LineRead {
+ for (;;) {
+ const end = this.bufferedLineEnd()
+ if (end >= 0) {
+ const raw = this.take(end + 1)
+ return {
+ line: stripTerminator(decoder.decode(raw)),
+ endedAtEof: false,
+ rawLength: raw.length
+ }
+ }
+ if (this.exhausted) {
+ const raw = this.take(this.pending.length)
+ return {
+ line: stripTerminator(decoder.decode(raw)),
+ endedAtEof: true,
+ rawLength: raw.length
+ }
+ }
+ const chunk = this.io.read(CHUNK)
+ if (chunk.length === 0) this.exhausted = true
+ else this.append(chunk)
+ }
+ }
+
+ /**
+ * A line read with echo suppressed.
+ *
+ * Buffered bytes win: if a full line is already in hand there is nothing to
+ * suppress and no reason to touch the terminal. Otherwise the descriptor is
+ * put in raw mode and drained a byte at a time, because raw mode also turns
+ * off the driver's line editing — backspace and Ctrl-C become our job. Go
+ * got line editing for free by clearing only `ECHO` via termios, which Node
+ * and Bun do not expose.
+ */
+ readSecretLine(): LineRead {
+ if (this.bufferedLineEnd() >= 0 || this.io.setRawMode === undefined) return this.readLine()
+
+ const setRawMode = this.io.setRawMode
+ const collected: Array = []
+ setRawMode(true)
+ try {
+ for (;;) {
+ // Bytes an earlier prompt over-read go through the SAME rules as bytes
+ // typed now. Seeding `collected` with them verbatim instead would let a
+ // trailing CR — a CRLF stream whose LF has not arrived yet — end up
+ // inside the secret, which neither Go path produces: the pipe path
+ // trims it with TrimRight and the TTY path drops CR in readPasswordLine.
+ const byte = this.pending.length > 0 ? this.take(1)[0]! : this.readByte()
+ if (byte === undefined) {
+ this.exhausted = true
+ return { line: decodeBytes(collected), endedAtEof: true, rawLength: collected.length }
+ }
+ if (byte === LF || byte === CR) {
+ return {
+ line: decodeBytes(collected),
+ endedAtEof: false,
+ rawLength: collected.length + 1
+ }
+ }
+ if (byte === DEL || byte === BACKSPACE) {
+ collected.pop()
+ continue
+ }
+ // Raw mode cleared ISIG, which Go's termios tweak deliberately kept.
+ // Surface it as an interrupt so the exit code is still 130.
+ if (byte === ETX) throw INTERRUPTED
+ collected.push(byte)
+ }
+ } finally {
+ setRawMode(false)
+ }
+ }
+
+ /** One byte from the descriptor, or `undefined` at EOF. */
+ private readByte(): number | undefined {
+ const chunk = this.io.read(1)
+ return chunk.length === 0 ? undefined : chunk[0]!
+ }
+}
+
+/**
+ * Go's `strings.TrimRight(line, "\r\n")` — it strips a *run* of CR and LF, not
+ * just one terminator, so `"abc\r\r\n"` yields `"abc"`.
+ */
+const stripTerminator = (line: string): string => line.replace(/[\r\n]+$/, "")
+
+const decodeBytes = (bytes: ReadonlyArray): string =>
+ decoder.decode(new Uint8Array(bytes))
+
+/**
+ * Build a `Prompts` implementation over `io`. Every prompt returned by one
+ * call shares a single reader; call this once per process.
+ */
+export const makePromptsWith = (io: PromptIO): PromptsShape => {
+ const stdin = new SharedStdin(io)
+
+ /** Ctrl-C in raw mode unwinds as an Effect interrupt, i.e. exit 130. */
+ const attempt = (thunk: () => A): Effect.Effect =>
+ Effect.suspend(() => {
+ try {
+ return Effect.succeed(thunk())
+ } catch (error) {
+ if (error === INTERRUPTED) return Effect.interrupt
+ return Effect.fail(
+ new OperationalError({
+ message: error instanceof Error ? error.message : String(error),
+ cause: error
+ })
+ )
+ }
+ })
+
+ return {
+ /**
+ * Go's OAuth client-ID prompt: an echoed line read where a read error is
+ * tolerated as long as something non-blank came back with it.
+ */
+ readLine: (prompt) =>
+ attempt(() => {
+ io.writeError(prompt)
+ return stdin.readLine()
+ }).pipe(
+ Effect.flatMap((read) =>
+ read.endedAtEof && read.line.trim() === ""
+ ? Effect.fail(new OperationalError({ message: "EOF" }))
+ : Effect.succeed(read.line)
+ )
+ ),
+
+ /**
+ * Go's `readSecret`: no echo on a TTY, a plain line read on a pipe, and
+ * EOF is never an error. The trailing newline is ours to print because
+ * nothing echoed the user's Enter.
+ */
+ readSecret: (prompt) =>
+ attempt(() => {
+ io.writeError(prompt)
+ const read = stdin.readSecretLine()
+ io.writeError("\n")
+ return Redacted.make(read.line)
+ }),
+
+ /**
+ * Go's `skills install` confirmation. Go built a fresh `bufio.Reader`
+ * here; the shared reader is used instead — strictly safer, and identical
+ * in behaviour because this is the command's only prompt.
+ */
+ confirm: (block) =>
+ attempt(() => {
+ io.writeError(block)
+ return stdin.readLine()
+ }).pipe(
+ Effect.flatMap((read) => {
+ // A read error with zero bytes is fatal; one that still produced
+ // bytes is tolerated. Go printed the trailing newline only after
+ // clearing that check. The check is on the RAW string, terminator
+ // included — a lone "\r" at EOF cancels rather than erroring.
+ if (read.endedAtEof && read.rawLength === 0) {
+ return Effect.fail(new OperationalError({ message: "EOF" }))
+ }
+ io.writeError("\n")
+ const answer = read.line.trim().toLowerCase()
+ return Effect.succeed(answer === "y" || answer === "yes")
+ })
+ )
+ }
+}
+
+export const makePrompts: Effect.Effect = Effect.sync(() =>
+ makePromptsWith(hostIO())
+)
+
+export const PromptsLive = Layer.effect(Prompts, makePrompts)
+
+/**
+ * A scripted `PromptIO` for tests: `input` is delivered in `chunkSize` pieces
+ * so the shared-reader invariant (bytes pulled for one prompt reaching the
+ * next) is actually exercised rather than assumed.
+ */
+export const testPromptIO = (
+ input: string,
+ options?: { readonly isInputTTY?: boolean; readonly chunkSize?: number }
+): PromptIO & {
+ readonly errorOutput: () => string
+ /** Every setRawMode call, so a test can prove echo really was suppressed. */
+ readonly rawModeCalls: () => ReadonlyArray
+} => {
+ const bytes = new TextEncoder().encode(input)
+ const chunkSize = options?.chunkSize ?? CHUNK
+ const written: Array = []
+ const rawModeCalls: Array = []
+ let offset = 0
+ const isInputTTY = options?.isInputTTY ?? false
+ return {
+ read: (size) => {
+ const take = Math.min(size, chunkSize, bytes.length - offset)
+ if (take <= 0) return new Uint8Array(0)
+ const slice = bytes.subarray(offset, offset + take)
+ offset += take
+ return slice
+ },
+ writeError: (text) => {
+ written.push(text)
+ },
+ isInputTTY,
+ setRawMode: isInputTTY
+ ? (enabled: boolean) => {
+ rawModeCalls.push(enabled)
+ }
+ : undefined,
+ errorOutput: () => written.join(""),
+ rawModeCalls: () => rawModeCalls
+ }
+}
diff --git a/src/impl/renderer.test.ts b/src/impl/renderer.test.ts
new file mode 100644
index 0000000..1679c05
--- /dev/null
+++ b/src/impl/renderer.test.ts
@@ -0,0 +1,216 @@
+import { describe, expect, test } from "bun:test"
+import { Effect, Layer, Result, Sink, Stdio } from "effect"
+import type { ListResult } from "../domain/listResult.ts"
+import { parseJson } from "../json/parse.ts"
+import type { JsonObject } from "../json/value.ts"
+import { Renderer, type RenderOptions } from "../services/index.ts"
+import { statusColumns, versionColumns } from "../output/columns.ts"
+import { makeRendererWith, RendererLive, renderObjectText, renderText } from "./renderer.ts"
+
+const obj = (text: string): JsonObject => {
+ const parsed = parseJson(text)
+ if (Result.isFailure(parsed)) throw new Error(parsed.failure.message)
+ return parsed.success as JsonObject
+}
+
+const items = (text: string): ReadonlyArray => {
+ const parsed = parseJson(text)
+ if (Result.isFailure(parsed)) throw new Error(parsed.failure.message)
+ return parsed.success as ReadonlyArray
+}
+
+const listOf = (text: string, nextPageToken = "", requests = 0): ListResult => ({
+ items: items(text),
+ nextPageToken,
+ requests
+})
+
+const opts = (
+ format: RenderOptions["format"],
+ columns: ReadonlyArray = [],
+ noHeader = false
+): RenderOptions => ({ format, columns, noHeader })
+
+/**
+ * Drive the real `RendererLive` through a `Stdio` layer that captures writes,
+ * so the service wiring — not just the pure text builders — is under test.
+ */
+const capture = (
+ use: (renderer: {
+ readonly render: (r: ListResult, o: RenderOptions) => Effect.Effect
+ readonly renderObject: (o: JsonObject, opt: RenderOptions) => Effect.Effect
+ }) => Effect.Effect
+): Promise => {
+ const chunks: Array = []
+ const stdio = Stdio.layerTest({
+ stdout: () =>
+ Sink.forEach((input: string | Uint8Array) =>
+ Effect.sync(() => {
+ chunks.push(typeof input === "string" ? input : new TextDecoder().decode(input))
+ })
+ )
+ })
+ return Effect.gen(function* () {
+ const renderer = yield* Renderer
+ yield* use(renderer)
+ return chunks.join("")
+ }).pipe(
+ Effect.provide(RendererLive.pipe(Layer.provide(stdio))),
+ Effect.runPromise
+ )
+}
+
+describe("Renderer service over Stdio", () => {
+ test("render writes the table to stdout", async () => {
+ const out = await capture((r) =>
+ r.render(listOf('[{"id":"v","snippet":{"title":"T"}}]'), opts("table", ["id", "snippet.title"]))
+ )
+ expect(out).toBe("ID SNIPPET.TITLE\nv T\n")
+ })
+
+ test("render writes the json envelope to stdout", async () => {
+ const out = await capture((r) => r.render(listOf('[{"id":"a"}]', "", 2), opts("json")))
+ expect(out).toBe('{\n "items": [\n {\n "id": "a"\n }\n ],\n "requests": 2\n}\n')
+ })
+
+ test("an empty jsonl result writes NOTHING at all", async () => {
+ const out = await capture((r) => r.render(listOf("[]"), opts("jsonl")))
+ expect(out).toBe("")
+ })
+
+ test("an empty table with --no-header writes nothing", async () => {
+ const out = await capture((r) => r.render(listOf("[]"), opts("table", ["id"], true)))
+ expect(out).toBe("")
+ })
+
+ test("renderObject writes a bare object with no envelope", async () => {
+ const out = await capture((r) =>
+ r.renderObject(obj('{"videoId":"abc","permitted":true}'), opts("json"))
+ )
+ expect(out).toBe('{\n "permitted": true,\n "videoId": "abc"\n}\n')
+ })
+
+ test("renderObject in table format is a single data row", async () => {
+ const out = await capture((r) =>
+ r.renderObject(obj('{"videoId":"abc","permitted":true}'), opts("table", ["videoId", "permitted"]))
+ )
+ expect(out).toBe("VIDEOID PERMITTED\nabc true\n")
+ })
+
+ test("everything is emitted in ONE write (tabwriter flushes once)", async () => {
+ const chunks: Array = []
+ const stdio = Stdio.layerTest({
+ stdout: () =>
+ Sink.forEach((input: string | Uint8Array) =>
+ Effect.sync(() => {
+ chunks.push(typeof input === "string" ? input : new TextDecoder().decode(input))
+ })
+ )
+ })
+ await Effect.gen(function* () {
+ const renderer = yield* Renderer
+ yield* renderer.render(
+ listOf('[{"id":"a"},{"id":"b"},{"id":"c"}]'),
+ opts("table", ["id", "snippet.title"])
+ )
+ }).pipe(Effect.provide(RendererLive.pipe(Layer.provide(stdio))), Effect.runPromise)
+ expect(chunks.length).toBe(1)
+ })
+})
+
+describe("makeRendererWith — write failures propagate", () => {
+ test("a failing sink surfaces the error rather than being swallowed", async () => {
+ const boom = new Error("closed pipe")
+ const renderer = makeRendererWith(() => Effect.fail(boom as never))
+ const exit = await Effect.runPromiseExit(
+ renderer.render(listOf('[{"id":"a"}]'), opts("json"))
+ )
+ expect(exit._tag).toBe("Failure")
+ })
+
+ test("no write is attempted when there is nothing to emit", async () => {
+ let calls = 0
+ const renderer = makeRendererWith(() => {
+ calls++
+ return Effect.void
+ })
+ await Effect.runPromise(renderer.render(listOf("[]"), opts("jsonl")))
+ expect(calls).toBe(0)
+ })
+
+ test("exactly one write for a non-empty render", async () => {
+ let calls = 0
+ const renderer = makeRendererWith(() => {
+ calls++
+ return Effect.void
+ })
+ await Effect.runPromise(renderer.render(listOf('[{"id":"a"}]'), opts("jsonl")))
+ expect(calls).toBe(1)
+ })
+})
+
+describe("renderText dispatch", () => {
+ test.each([
+ ["json", '{\n "items": [\n {\n "id": "a"\n }\n ],\n "requests": 0\n}\n'],
+ ["jsonl", '{"id":"a"}\n'],
+ ["table", "ID\na\n"],
+ ["tsv", "ID\na\n"]
+ ] as const)("%s", (format, want) => {
+ expect(renderText(listOf('[{"id":"a"}]'), opts(format, ["id"]))).toBe(want)
+ })
+
+ test("table and tsv fall back to id,snippet.title when no columns are given", () => {
+ expect(renderText(listOf('[{"id":"v","snippet":{"title":"T"}}]'), opts("table"))).toBe(
+ "ID SNIPPET.TITLE\nv T\n"
+ )
+ expect(renderText(listOf('[{"id":"v","snippet":{"title":"T"}}]'), opts("tsv"))).toBe(
+ "ID\tSNIPPET.TITLE\nv\tT\n"
+ )
+ })
+
+ test("json and jsonl ignore --columns entirely", () => {
+ const withCols = renderText(listOf('[{"id":"a","x":"b"}]'), opts("json", ["id"]))
+ const without = renderText(listOf('[{"id":"a","x":"b"}]'), opts("json"))
+ expect(withCols).toBe(without)
+ expect(withCols).toContain('"x": "b"')
+ })
+
+ test("json and jsonl ignore --no-header entirely", () => {
+ expect(renderText(listOf('[{"id":"a"}]'), opts("jsonl", [], true))).toBe(
+ renderText(listOf('[{"id":"a"}]'), opts("jsonl", [], false))
+ )
+ })
+})
+
+describe("renderObjectText dispatch", () => {
+ test("the version payload matches the Go binary", () => {
+ const state = obj(
+ '{"version":"v0.3.3","commit":"699879f7","date":"2026-07-25T05:47:34Z","goVersion":"go1.26.5","os":"darwin","arch":"arm64"}'
+ )
+ expect(renderObjectText(state, opts("json", versionColumns))).toBe(
+ '{\n "arch": "arm64",\n "commit": "699879f7",\n "date": "2026-07-25T05:47:34Z",\n "goVersion": "go1.26.5",\n "os": "darwin",\n "version": "v0.3.3"\n}\n'
+ )
+ expect(renderObjectText(state, opts("tsv", versionColumns))).toBe(
+ "VERSION\tCOMMIT\tDATE\tGOVERSION\tOS\tARCH\n" +
+ "v0.3.3\t699879f7\t2026-07-25T05:47:34Z\tgo1.26.5\tdarwin\tarm64\n"
+ )
+ })
+
+ test("the status payload matches the Go binary", () => {
+ // Captured from `OYTC_CONFIG_DIR=/tmp/p3-cfg oytc status --format tsv` with
+ // no credentials present: absent keys render as empty cells.
+ const state = obj(
+ '{"api_key":{"configured":false,"source":"none"},"oauth":{"configured":false},"path":"/tmp/p3-cfg/auth.json"}'
+ )
+ expect(renderObjectText(state, opts("tsv", statusColumns))).toBe(
+ "PATH\tAPI_KEY.CONFIGURED\tAPI_KEY.SOURCE\tAPI_KEY.FINGERPRINT\tOAUTH.CONFIGURED\tOAUTH.CLIENT_ID\tOAUTH.SCOPES\tOAUTH.EXPIRY\n" +
+ "/tmp/p3-cfg/auth.json\tfalse\tnone\t\tfalse\t\t\t\n"
+ )
+ })
+
+ test("renderObject in table/tsv falls back to id,snippet.title without columns", () => {
+ expect(renderObjectText(obj('{"version":"1.2.3","id":"x"}'), opts("tsv"))).toBe(
+ "ID\tSNIPPET.TITLE\nx\t\n"
+ )
+ })
+})
diff --git a/src/impl/renderer.ts b/src/impl/renderer.ts
index ca14f9a..3d7d9f4 100644
--- a/src/impl/renderer.ts
+++ b/src/impl/renderer.ts
@@ -1,2 +1,108 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Renderer — the `internal/output/output.go` dispatch, writing to stdout.
+ *
+ * `Render` and `RenderObject` in Go both take an `io.Writer`; here the seam is
+ * the Effect `Stdio` service, so tests can capture output with
+ * `Stdio.layerTest` and production gets `BunServices.layer`.
+ *
+ * The whole render is built as one string and written once. That is not just
+ * convenient — Go's tabwriter buffers every row and emits nothing until
+ * `Flush()`, so a single terminal write is the faithful behavior.
+ *
+ * NOTE on the format check: Go's `Render` rejects an unknown format with
+ * `unsupported format %q (use table, json, jsonl, or tsv)`, which `renderResult`
+ * wraps into a UsageError (exit 2). Here `OutputFormat` is a four-member union,
+ * so that branch is unreachable by construction and the exhaustive switch has no
+ * default arm.
+ */
+
+import { Effect, Layer, Stdio, Stream } from "effect"
+import { OperationalError } from "../domain/errors.ts"
+import type { ListResult } from "../domain/listResult.ts"
+import type { JsonObject } from "../json/value.ts"
+import { Renderer, type RendererShape, type RenderOptions } from "../services/index.ts"
+import { fallbackColumns, generateRows } from "../output/columns.ts"
+import { renderJson, renderJsonl, renderObjectJson, renderObjectJsonl } from "../output/jsonOut.ts"
+import { renderTable } from "../output/table.ts"
+import { renderTsv } from "../output/tsv.ts"
+
+/** `renderRows`: the shared table/tsv path, columns fallback included. */
+const rowText = (
+ items: ReadonlyArray,
+ options: RenderOptions,
+ format: "table" | "tsv"
+): string => {
+ const columns = options.columns.length > 0 ? options.columns : fallbackColumns
+ const rows = generateRows(items, columns, options.noHeader)
+ return format === "table" ? renderTable(rows) : renderTsv(rows)
+}
+
+/** The exact bytes `Render` writes for a list result. */
+export const renderText = (result: ListResult, options: RenderOptions): string => {
+ switch (options.format) {
+ case "json":
+ return renderJson(result)
+ case "jsonl":
+ return renderJsonl(result)
+ case "table":
+ case "tsv":
+ return rowText(result.items, options, options.format)
+ }
+}
+
+/** The exact bytes `RenderObject` writes for a single object. */
+export const renderObjectText = (object: JsonObject, options: RenderOptions): string => {
+ switch (options.format) {
+ case "json":
+ return renderObjectJson(object)
+ case "jsonl":
+ return renderObjectJsonl(object)
+ case "table":
+ case "tsv":
+ return rowText([object], options, options.format)
+ }
+}
+
+/**
+ * Build a Renderer over an arbitrary sink. Exported so tests (and any future
+ * non-stdout consumer) can drive the same dispatch without a platform layer.
+ */
+export const makeRendererWith = (
+ write: (text: string) => Effect.Effect
+): RendererShape => ({
+ render: (result, options) => {
+ const text = renderText(result, options)
+ return text === "" ? Effect.void : write(text)
+ },
+ renderObject: (object, options) => {
+ const text = renderObjectText(object, options)
+ return text === "" ? Effect.void : write(text)
+ }
+})
+
+/**
+ * Renderer over the process's stdout.
+ *
+ * A write failure (a closed pipe, most plausibly) surfaces as an
+ * OperationalError / exit 6. Go would have wrapped it into a UsageError and
+ * exited 2, which is an artifact of `renderResult` funnelling every `Render`
+ * error — including I/O ones — through the "unsupported format" path. Exit 6 is
+ * the documented bucket for I/O failure and is the intentional reading here.
+ */
+export const makeRenderer: Effect.Effect = Effect.gen(
+ function* () {
+ const stdio = yield* Stdio.Stdio
+ return makeRendererWith((text) =>
+ Stream.run(Stream.make(text), stdio.stdout()).pipe(
+ Effect.catch((cause) =>
+ Effect.fail(new OperationalError({ message: "could not write output", cause }))
+ )
+ )
+ )
+ }
+)
+
+export const RendererLive: Layer.Layer = Layer.effect(
+ Renderer,
+ makeRenderer
+)
diff --git a/src/impl/resolveChannel.test.ts b/src/impl/resolveChannel.test.ts
new file mode 100644
index 0000000..68cfb8a
--- /dev/null
+++ b/src/impl/resolveChannel.test.ts
@@ -0,0 +1,481 @@
+/**
+ * Channel-resolution tests.
+ *
+ * Ported from `internal/youtube/client_test.go`:
+ * TestResolveChannelHandleAndURL
+ *
+ * The classification table below is a GOLDEN CORPUS captured by running each
+ * input through the real Go `parseChannelReference` (Go 1.26.5, `go run`), not
+ * from reading the spec. The full corpus — 4,497 structured combinations plus
+ * 11,398 random fuzz strings — was diffed against this implementation and
+ * matched on every row; these are the readable representatives.
+ *
+ * That exercise found one real bug: Go's `url.Parse` rejects a `%XX` escape in
+ * the HOST whose high nibble is `< 8` (unless it is literally `%25`), so
+ * `//youtube%2ecom/@x` is a parse error and classifies as a keyword search. A
+ * naive decoder sees `youtube.com` and calls it a handle. 100 corpus rows
+ * hinged on it.
+ */
+
+import { describe, expect, test } from "bun:test"
+import { Cause, Effect, Exit, Option } from "effect"
+import { NotFoundError, OperationalError } from "../domain/errors.ts"
+import { parseJson } from "../json/parse.ts"
+import type { JsonValue } from "../json/value.ts"
+import type { DataApiResponse } from "../schema/dataapi.ts"
+import type { Params } from "../services/index.ts"
+import {
+ CHANNEL_ID_PATTERN,
+ goQuote,
+ goUrlParse,
+ parseChannelReference,
+ resolveChannelWith,
+ type ChannelReferenceKind
+} from "./resolveChannel.ts"
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
+interface SeenGet {
+ readonly resource: string
+ readonly params: Params
+}
+
+const response = (text: string): DataApiResponse => {
+ const parsed = parseJson(text)
+ if (parsed._tag === "Failure") throw new Error("bad fixture")
+ return parsed.success as unknown as DataApiResponse
+}
+
+const harness = (bodies: ReadonlyArray) => {
+ const seen: Array = []
+ const get = (resource: string, params: Params) => {
+ seen.push({ resource, params })
+ return Effect.succeed(response(bodies[Math.min(seen.length - 1, bodies.length - 1)] ?? "{}"))
+ }
+ const resolve = resolveChannelWith(get)
+ return {
+ seen,
+ run: (reference: string) => Effect.runPromise(Effect.exit(resolve(reference)))
+ }
+}
+
+const okOf = (exit: Exit.Exit): A => {
+ if (!Exit.isSuccess(exit)) throw new Error(`expected success: ${Cause.pretty(exit.cause)}`)
+ return exit.value
+}
+
+const errOf = (exit: Exit.Exit): E => {
+ if (!Exit.isFailure(exit)) throw new Error("expected failure")
+ const found = Cause.findErrorOption(exit.cause)
+ if (!Option.isSome(found)) throw new Error("no error in cause")
+ return found.value
+}
+
+const param = (params: Params, key: string): string | undefined =>
+ params.find(([k]) => k === key)?.[1]
+
+// ---------------------------------------------------------------------------
+// goQuote
+// ---------------------------------------------------------------------------
+
+describe("goQuote", () => {
+ // Verified against Go 1.26.5 strconv.Quote.
+ test.each([
+ ["@example", '"@example"'],
+ ["Some Channel", '"Some Channel"'],
+ ['a"b', '"a\\"b"'],
+ ["a\\b", '"a\\\\b"'],
+ ["a\nb", '"a\\nb"'],
+ ["a\tb", '"a\\tb"'],
+ ["a\x00b", '"a\\x00b"'],
+ ["a\x1bb", '"a\\x1bb"'],
+ ["a\x7fb", '"a\\x7fb"'],
+ ["a'b", "\"a'b\""],
+ ["\x07\b\f\v", '"\\a\\b\\f\\v"'],
+ ["café", '"café"'],
+ ["日本", '"日本"'],
+ ["emoji \u{1F389}", '"emoji \u{1F389}"'],
+ ["nbsp x", '"nbsp\\u00a0x"'],
+ ["zwspx", '"zwsp\\u200bx"'],
+ ["bomx", '"bom\\ufeffx"'],
+ ["line
sep", '"line\\u2028sep"'],
+ ["
next", '"\\u0085next"'],
+ [" ideographic", '"\\u3000ideographic"'],
+ ["unassigned\u{e0000}x", '"unassigned\\U000e0000x"']
+ ])("quotes %j", (input, expected) => {
+ expect(goQuote(input)).toBe(expected)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// goUrlParse
+// ---------------------------------------------------------------------------
+
+describe("goUrlParse", () => {
+ test("does not lowercase the host, unlike new URL", () => {
+ expect(goUrlParse("https://WWW.YOUTUBE.COM/@Foo")?.hostname).toBe("WWW.YOUTUBE.COM")
+ expect(new URL("https://WWW.YOUTUBE.COM/@Foo").hostname).toBe("www.youtube.com")
+ })
+
+ test("percent-decodes the path, unlike new URL", () => {
+ expect(goUrlParse("https://youtube.com/%40handle")?.path).toBe("/@handle")
+ expect(new URL("https://youtube.com/%40handle").pathname).toBe("/%40handle")
+ })
+
+ test("a scheme-relative //host DOES yield an authority", () => {
+ // Verified in Go: url.Parse("//youtube.com/@x").Host == "youtube.com".
+ // The classifier still calls it a search, because the "://" test fails and
+ // no "https://" prefix is prepended — so the reference reaches parse with a
+ // host but is not what the caller typed. See the golden corpus.
+ expect(goUrlParse("//youtube.com/@x")?.host).toBe("youtube.com")
+ })
+
+ test("three slashes after a scheme leave no authority", () => {
+ // url.Parse("https:////youtube.com/@x").Host == "" — verified in Go.
+ expect(goUrlParse("https:////youtube.com/@x")?.host).toBe("")
+ })
+
+ test("strips userinfo from the authority", () => {
+ expect(goUrlParse("https://user:pass@youtube.com/@x")?.hostname).toBe("youtube.com")
+ })
+
+ test("Hostname drops the port and unwraps IPv6 brackets", () => {
+ expect(goUrlParse("https://youtube.com:8080/@x")?.hostname).toBe("youtube.com")
+ expect(goUrlParse("https://[::1]/@x")?.hostname).toBe("::1")
+ })
+
+ test.each([
+ ["a control byte", "https://youtube.com/@x\ty"],
+ ["a truncated escape", "https://youtube.com/@x%"],
+ ["a bad escape", "https://youtube.com/@x%2G"],
+ ["a space in the host", "https://yout ube.com/@x"],
+ ["a bad escape in the fragment", "https://youtube.com/@x#f%zz"],
+ ["a low escape in the host", "https://youtube%2ecom/@x"]
+ ])("rejects %s", (_label, input) => {
+ expect(goUrlParse(input)).toBeUndefined()
+ })
+
+ test("does NOT validate query escapes", () => {
+ expect(goUrlParse("https://youtube.com/@x?q=%zz")?.path).toBe("/@x")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// parseChannelReference — golden corpus
+// ---------------------------------------------------------------------------
+
+describe("parseChannelReference (golden, captured from Go 1.26.5)", () => {
+ const golden: ReadonlyArray = [
+ // Bare handles.
+ ["@example", "handle", "@example"],
+ ["@", "handle", "@"],
+ [" @x", "search", " @x"],
+
+ // Canonical URL forms.
+ ["https://youtube.com/@example/videos", "handle", "@example"],
+ ["youtube.com/@example", "handle", "@example"],
+ ["https://www.youtube.com/@x", "handle", "@x"],
+ ["www.youtube.com/channel/UC1234567890123456789012", "id", "UC1234567890123456789012"],
+ [
+ "https://youtube.com/channel/UC1234567890123456789012/videos",
+ "id",
+ "UC1234567890123456789012"
+ ],
+ ["https://m.youtube.com/user/someuser", "username", "someuser"],
+ ["https://youtube.com/c/SomeName", "search", "SomeName"],
+ ["https://m.youtube.com/@x", "handle", "@x"],
+
+ // Host casing: TrimPrefix("www.") runs BEFORE ToLower, so an uppercase
+ // "WWW." survives and the host test fails.
+ ["https://WWW.YOUTUBE.COM/@Foo", "search", "https://WWW.YOUTUBE.COM/@Foo"],
+ ["WWW.youtube.com/@x", "search", "WWW.youtube.com/@x"],
+ // ...but a host with no "www." prefix lowercases fine.
+ ["https://M.YOUTUBE.COM/@x", "handle", "@x"],
+ ["M.Youtube.com/@x", "search", "M.Youtube.com/@x"],
+ ["https://www.m.youtube.com/@x", "handle", "@x"],
+
+ // The scheme is never checked.
+ ["ftp://youtube.com/@x", "handle", "@x"],
+ ["HTTPS://youtube.com/@x", "handle", "@x"],
+
+ // Non-YouTube and near-miss hosts.
+ ["https://youtu.be/@x", "search", "https://youtu.be/@x"],
+ ["youtu.be/@x", "search", "youtu.be/@x"],
+ ["https://music.youtube.com/@x", "search", "https://music.youtube.com/@x"],
+ ["https://youtube.com./@x", "search", "https://youtube.com./@x"],
+ ["xyoutube.com/@z", "search", "xyoutube.com/@z"],
+ ["https://[::1]/@x", "search", "https://[::1]/@x"],
+
+ // A scheme-relative reference has no authority in Go.
+ ["//youtube.com/@x", "search", "//youtube.com/@x"],
+
+ // Userinfo and ports.
+ ["https://user:pass@youtube.com/@x", "handle", "@x"],
+ ["https://youtube.com:8080/@x", "handle", "@x"],
+ ["http://www.youtube.com:8080/channel/UCabc", "id", "UCabc"],
+
+ // Paths that do not resolve to a known prefix.
+ ["https://youtube.com/", "search", "https://youtube.com/"],
+ ["https://youtube.com", "search", "https://youtube.com"],
+ ["youtube.com/", "search", "youtube.com/"],
+ ["https://youtube.com/channel", "search", "https://youtube.com/channel"],
+ ["https://youtube.com/channel/", "search", "https://youtube.com/channel/"],
+ ["https://youtube.com/user", "search", "https://youtube.com/user"],
+ ["https://youtube.com/user/", "search", "https://youtube.com/user/"],
+ ["https://youtube.com/c/", "search", "https://youtube.com/c/"],
+ ["https://youtube.com/watch?v=x", "search", "https://youtube.com/watch?v=x"],
+ // The prefix match is case-sensitive.
+ ["https://youtube.com/CHANNEL/UCabc", "search", "https://youtube.com/CHANNEL/UCabc"],
+ ["https://youtube.com/C/SomeName", "search", "https://youtube.com/C/SomeName"],
+
+ // Only the FIRST two path segments matter; extras are ignored.
+ ["https://youtube.com/user/a/b/c", "username", "a"],
+ ["https://youtube.com/channel/a/b", "id", "a"],
+ ["https://youtube.com/@x/@y", "handle", "@x"],
+
+ // Slash collapsing via strings.Trim(path, "/").
+ ["https://youtube.com//@x", "handle", "@x"],
+ ["https://youtube.com///@x", "handle", "@x"],
+ // ...but "." is a real segment, not normalized away.
+ ["https://youtube.com/./@x", "search", "https://youtube.com/./@x"],
+
+ // The path is percent-DECODED before segmentation.
+ ["https://youtube.com/%40handle", "handle", "@handle"],
+ ["https://youtube.com/@%C3%A9", "handle", "@é"],
+ ["https://youtube.com/@x%20y", "handle", "@x y"],
+ ["https://youtube.com/@x%2Fy", "handle", "@x"],
+ ["https://youtube.com/a%2Fb/@x", "search", "https://youtube.com/a%2Fb/@x"],
+ ["https://youtube.com/%2540handle", "search", "https://youtube.com/%2540handle"],
+
+ // Query and fragment are split off before the path is read.
+ ["https://youtube.com/@x?q=1", "handle", "@x"],
+ ["https://youtube.com/@x?", "handle", "@x"],
+ ["https://youtube.com/@x#frag", "handle", "@x"],
+ ["https://youtube.com/@x#", "handle", "@x"],
+ // Query escapes are not validated; fragment escapes are.
+ ["https://youtube.com/@x?q=%zz", "handle", "@x"],
+ ["https://youtube.com/@x#f%zz", "search", "https://youtube.com/@x#f%zz"],
+
+ // url.Parse failures all fall through to a keyword search.
+ ["https://youtube.com/@x%", "search", "https://youtube.com/@x%"],
+ ["https://youtube.com/@x%2G", "search", "https://youtube.com/@x%2G"],
+ ["https://yout ube.com/@x", "search", "https://yout ube.com/@x"],
+ // A %XX in the host with a high nibble < 8 is rejected outright.
+ ["https://youtube%2ecom/@x", "search", "https://youtube%2ecom/@x"],
+
+ // Characters url.Parse tolerates in a path.
+ ["https://youtube.com/@x y", "handle", "@x y"],
+ ["https://youtube.com/@x|y", "handle", "@x|y"],
+ ['https://youtube.com/@x"y', "handle", '@x"y'],
+ ["https://youtube.com/@x[1]", "handle", "@x[1]"],
+ ["https://youtube.com/@café", "handle", "@café"],
+ ["https://youtube.com/@日本", "handle", "@日本"],
+ ["https://youtube.com/@", "handle", "@"],
+
+ // Plain keywords.
+ ["Some Channel", "search", "Some Channel"],
+ ["UC1234567890123456789012", "search", "UC1234567890123456789012"],
+ ["mailto:x@y.com", "search", "mailto:x@y.com"],
+ ["a:b/c", "search", "a:b/c"]
+ ]
+
+ test.each(golden)("%j -> %s %j", (input, kind, value) => {
+ expect(parseChannelReference(input)).toEqual({ kind, value })
+ })
+
+ test("the golden corpus covers every kind", () => {
+ const kinds = new Set(golden.map(([, kind]) => kind))
+ expect([...kinds].sort()).toEqual(["handle", "id", "search", "username"])
+ })
+})
+
+// ---------------------------------------------------------------------------
+// CHANNEL_ID_PATTERN
+// ---------------------------------------------------------------------------
+
+describe("CHANNEL_ID_PATTERN", () => {
+ test("accepts exactly 24 characters starting with UC", () => {
+ expect(CHANNEL_ID_PATTERN.test("UC1234567890123456789012")).toBe(true)
+ expect("UC1234567890123456789012".length).toBe(24)
+ })
+
+ test.each([
+ ["too short", "UC123456789012345678901"],
+ ["too long", "UC12345678901234567890123"],
+ ["wrong prefix", "AB1234567890123456789012"],
+ ["lowercase prefix", "uc1234567890123456789012"],
+ ["an illegal character", "UC123456789012345678901!"],
+ ["empty", ""]
+ ])("rejects %s", (_label, input) => {
+ expect(CHANNEL_ID_PATTERN.test(input)).toBe(false)
+ })
+
+ test("accepts the URL-safe base64 alphabet", () => {
+ expect(CHANNEL_ID_PATTERN.test("UCabcXYZ012_-abcXYZ01234")).toBe(true)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// resolveChannel
+// ---------------------------------------------------------------------------
+
+describe("resolveChannel", () => {
+ // Go: TestResolveChannelHandleAndURL
+ test("resolves a handle URL through channels?forHandle, one request", async () => {
+ const h = harness([`{"items":[{"id":"UC1234567890123456789012"}]}`])
+ const result = okOf(await h.run("https://youtube.com/@example/videos"))
+
+ expect(result).toEqual({ id: "UC1234567890123456789012", requests: 1 })
+ expect(h.seen).toHaveLength(1)
+ expect(h.seen[0]!.resource).toBe("channels")
+ expect(param(h.seen[0]!.params, "forHandle")).toBe("example")
+ expect(param(h.seen[0]!.params, "part")).toBe("id")
+ })
+
+ // Go: the same test asserts a canonical UC… id costs zero requests.
+ test("a UC… id short-circuits with zero requests", async () => {
+ const h = harness([`{"items":[]}`])
+ expect(okOf(await h.run("UC1234567890123456789012"))).toEqual({
+ id: "UC1234567890123456789012",
+ requests: 0
+ })
+ expect(h.seen).toHaveLength(0)
+ })
+
+ test("a bare @handle strips only the leading @", async () => {
+ const h = harness([`{"items":[{"id":"UCz"}]}`])
+ okOf(await h.run("@ex@mple"))
+ expect(param(h.seen[0]!.params, "forHandle")).toBe("ex@mple")
+ })
+
+ test("a /user/ URL uses forUsername", async () => {
+ const h = harness([`{"items":[{"id":"UCu"}]}`])
+ const result = okOf(await h.run("https://m.youtube.com/user/someuser"))
+ expect(result).toEqual({ id: "UCu", requests: 1 })
+ expect(param(h.seen[0]!.params, "forUsername")).toBe("someuser")
+ expect(param(h.seen[0]!.params, "forHandle")).toBeUndefined()
+ })
+
+ test("a /channel/ URL short-circuits when the ID is valid", async () => {
+ const h = harness(["{}"])
+ expect(okOf(await h.run("https://youtube.com/channel/UC1234567890123456789012"))).toEqual({
+ id: "UC1234567890123456789012",
+ requests: 0
+ })
+ expect(h.seen).toHaveLength(0)
+ })
+
+ test("a /channel/ URL with a malformed ID is an error, not a search", async () => {
+ const h = harness(["{}"])
+ const error = errOf(await h.run("https://youtube.com/channel/UCabc"))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toBe('invalid channel ID "UCabc"')
+ expect(h.seen).toHaveLength(0)
+ })
+
+ test("a keyword reads the NESTED id.channelId from search", async () => {
+ const h = harness([`{"items":[{"id":{"kind":"youtube#channel","channelId":"UCsearched"}}]}`])
+ const result = okOf(await h.run("Some Channel"))
+
+ expect(result).toEqual({ id: "UCsearched", requests: 1 })
+ expect(h.seen[0]!.resource).toBe("search")
+ expect(param(h.seen[0]!.params, "part")).toBe("snippet")
+ expect(param(h.seen[0]!.params, "type")).toBe("channel")
+ expect(param(h.seen[0]!.params, "q")).toBe("Some Channel")
+ expect(param(h.seen[0]!.params, "maxResults")).toBe("1")
+ })
+
+ test("a /c/ URL searches for the segment, not the whole URL", async () => {
+ const h = harness([`{"items":[{"id":{"channelId":"UCc"}}]}`])
+ okOf(await h.run("https://youtube.com/c/SomeName"))
+ expect(param(h.seen[0]!.params, "q")).toBe("SomeName")
+ })
+
+ test("the channels path reads a FLAT string id, unlike search", async () => {
+ // An object-valued id on the channels path yields "not found".
+ const h = harness([`{"items":[{"id":{"channelId":"UCnested"}}]}`])
+ expect(errOf(await h.run("@example"))).toBeInstanceOf(NotFoundError)
+ })
+
+ describe("not found", () => {
+ test("empty items on the channels path", async () => {
+ const h = harness([`{"items":[]}`])
+ const error = errOf(await h.run("@example"))
+ expect(error).toBeInstanceOf(NotFoundError)
+ expect(error.message).toBe('channel "@example" not found')
+ })
+
+ test("empty items on the search path", async () => {
+ const h = harness([`{"items":[]}`])
+ const error = errOf(await h.run("Some Channel"))
+ expect(error.message).toBe('channel "Some Channel" not found')
+ })
+
+ test("an item with no id", async () => {
+ const h = harness([`{"items":[{"snippet":{"title":"t"}}]}`])
+ expect(errOf(await h.run("@example"))).toBeInstanceOf(NotFoundError)
+ })
+
+ test("an item with an empty-string id", async () => {
+ const h = harness([`{"items":[{"id":""}]}`])
+ expect(errOf(await h.run("@example"))).toBeInstanceOf(NotFoundError)
+ })
+
+ test("a search item with no channelId", async () => {
+ const h = harness([`{"items":[{"id":{"kind":"youtube#channel"}}]}`])
+ expect(errOf(await h.run("Some Channel"))).toBeInstanceOf(NotFoundError)
+ })
+
+ test("a search item whose id is a flat string", async () => {
+ const h = harness([`{"items":[{"id":"UCflat"}]}`])
+ expect(errOf(await h.run("Some Channel"))).toBeInstanceOf(NotFoundError)
+ })
+
+ test("the message quotes the TRIMMED reference with Go's %q", async () => {
+ const h = harness([`{"items":[]}`])
+ const error = errOf(await h.run(' a"b '))
+ expect(error.message).toBe('channel "a\\"b" not found')
+ })
+ })
+
+ describe("empty reference", () => {
+ test.each([["empty", ""], ["spaces", " "], ["a tab", "\t"], ["a newline", "\n"]])(
+ "%s is rejected before any request",
+ async (_label, input) => {
+ const h = harness(["{}"])
+ const error = errOf(await h.run(input))
+ expect(error).toBeInstanceOf(OperationalError)
+ expect(error.message).toBe("channel reference cannot be empty")
+ expect(h.seen).toHaveLength(0)
+ }
+ )
+ })
+
+ test("the reference is trimmed before classification", async () => {
+ const h = harness(["{}"])
+ expect(okOf(await h.run(" UC1234567890123456789012 "))).toEqual({
+ id: "UC1234567890123456789012",
+ requests: 0
+ })
+ })
+
+ test("an upstream error propagates unchanged", async () => {
+ const failing = (resource: string, _params: Params) =>
+ Effect.fail(new OperationalError({ message: `boom on ${resource}` }))
+ const exit = await Effect.runPromise(
+ Effect.exit(resolveChannelWith(failing)("@example"))
+ )
+ expect(errOf(exit).message).toBe("boom on channels")
+ })
+})
+
+test("a resolved id round-trips through the JSON value model unchanged", () => {
+ // Guards against an accessor accidentally coercing a RawNumber-shaped id.
+ const parsed = parseJson(`{"items":[{"id":"UC1234567890123456789012"}]}`)
+ expect(parsed._tag).toBe("Success")
+ if (parsed._tag !== "Success") throw new Error("unreachable")
+ const value: JsonValue = parsed.success
+ expect(typeof value).toBe("object")
+})
diff --git a/src/impl/resolveChannel.ts b/src/impl/resolveChannel.ts
index ca14f9a..755c39a 100644
--- a/src/impl/resolveChannel.ts
+++ b/src/impl/resolveChannel.ts
@@ -1,2 +1,406 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Channel-reference resolution — a port of `internal/youtube/list.go`'s
+ * `ResolveChannel` / `parseChannelReference`.
+ *
+ * A reference is a `UC…` ID, an `@handle`, or a YouTube URL. The classifier is
+ * deliberately quirky and the quirks are load-bearing, so this file reproduces
+ * Go's `net/url` semantics rather than reaching for `new URL`. The two differ
+ * in ways the classifier can actually observe, all verified against Go 1.26.5:
+ *
+ * | reference | Go | `new URL` |
+ * |----------------------------------|--------------|------------------|
+ * | `//youtube.com/@x` | search | handle `@x` |
+ * | `https://WWW.YOUTUBE.COM/@Foo` | search | handle `@Foo` |
+ * | `https://youtube.com/%40handle` | handle `@handle` | (no match) |
+ * | `https://youtube.com/@xy` | search (err) | handle `@xy` |
+ * | `https://youtube.com/@x%` | search (err) | handle `@x%` |
+ *
+ * The `WWW.YOUTUBE.COM` row is the important one: Go's `url.Parse` does not
+ * lowercase the host, and `strings.TrimPrefix(host, "www.")` runs BEFORE
+ * `strings.ToLower`, so an uppercase `WWW.` prefix survives and the host test
+ * fails. `new URL` lowercases eagerly and would silently "fix" it.
+ */
+
+import { Effect } from "effect"
+import { NotFoundError, OperationalError, type OytcError } from "../domain/errors.ts"
+import { searchItemChannelId, channelItemId } from "../schema/accessors.ts"
+import type { DataApiResponse } from "../schema/dataapi.ts"
+import type { JsonObject } from "../json/value.ts"
+import type { Params, ResolvedChannel } from "../services/index.ts"
+
+/** `^UC[A-Za-z0-9_-]{22}$` — exactly 24 characters. */
+export const CHANNEL_ID_PATTERN = /^UC[A-Za-z0-9_-]{22}$/
+
+// ---------------------------------------------------------------------------
+// Go string formatting
+// ---------------------------------------------------------------------------
+
+const HEX_LOWER = "0123456789abcdef"
+
+/**
+ * Go's `unicode.IsPrint`: general categories L, M, N, P, S, plus the ASCII
+ * space. Notably NOT other space separators (U+00A0, U+2000, U+3000), format
+ * characters (U+200B, U+FEFF), line/paragraph separators, private use, or
+ * unassigned code points — all of which Go escapes.
+ */
+const PRINTABLE = /^[\p{L}\p{M}\p{N}\p{P}\p{S}]$/u
+
+const SHORT_ESCAPES: Readonly> = {
+ 0x07: "\\a",
+ 0x08: "\\b",
+ 0x0c: "\\f",
+ 0x0a: "\\n",
+ 0x0d: "\\r",
+ 0x09: "\\t",
+ 0x0b: "\\v"
+}
+
+/**
+ * Go's `strconv.Quote`, which is what `fmt.Errorf("%q")` uses for the two
+ * user-facing messages this file produces.
+ *
+ * Verified against Go 1.26.5: `\a\b\f\v` short escapes, `\x1b` for other
+ * control bytes, ` ` / `` / `` / `
` for non-printable
+ * BMP code points, `\U000e0000` for non-printable astral ones, and literal
+ * pass-through for `café`, `日本` and emoji.
+ */
+export const goQuote = (s: string): string => {
+ let out = '"'
+ for (const char of s) {
+ const code = char.codePointAt(0)!
+ if (char === '"') {
+ out += '\\"'
+ } else if (char === "\\") {
+ out += "\\\\"
+ } else if (SHORT_ESCAPES[code] !== undefined) {
+ out += SHORT_ESCAPES[code]
+ } else if (code >= 0x20 && code < 0x7f) {
+ out += char
+ } else if (code < 0x80) {
+ out += `\\x${HEX_LOWER[(code >> 4) & 0xf]}${HEX_LOWER[code & 0xf]}`
+ } else if (PRINTABLE.test(char)) {
+ out += char
+ } else if (code < 0x10000) {
+ out += `\\u${code.toString(16).padStart(4, "0")}`
+ } else {
+ out += `\\U${code.toString(16).padStart(8, "0")}`
+ }
+ }
+ return `${out}"`
+}
+
+// ---------------------------------------------------------------------------
+// A faithful subset of Go's net/url.Parse
+// ---------------------------------------------------------------------------
+
+export interface GoUrl {
+ /** Empty when the URL has no authority component. */
+ readonly host: string
+ /** `Hostname()` — `host` without its port and without IPv6 brackets. */
+ readonly hostname: string
+ /** `Path` — percent-DECODED, so `%40` has already become `@`. */
+ readonly path: string
+}
+
+const isHexDigit = (c: string): boolean => /^[0-9A-Fa-f]$/.test(c)
+
+/** `stringContainsCTLByte` — any byte `< 0x20` or `== 0x7f`. */
+const containsControl = (s: string): boolean => {
+ for (const char of s) {
+ const code = char.codePointAt(0)!
+ if (code < 0x20 || code === 0x7f) return true
+ }
+ return false
+}
+
+/** Go's `unescape` escape validation; returns false on a malformed `%XY`. */
+const escapesValid = (s: string): boolean => {
+ for (let i = 0; i < s.length; i++) {
+ if (s[i] !== "%") continue
+ if (i + 2 >= s.length || !isHexDigit(s[i + 1]!) || !isHexDigit(s[i + 2]!)) return false
+ i += 2
+ }
+ return true
+}
+
+/**
+ * Percent-decode. Go produces raw bytes here, which may be invalid UTF-8; a
+ * non-fatal decode turns those into U+FFFD instead. Only reachable via an
+ * exotic hand-written URL, and the resulting path segment is looked up
+ * remotely either way.
+ */
+const percentDecode = (s: string): string => {
+ if (!s.includes("%")) return s
+ const bytes: Array = []
+ const raw = new TextEncoder().encode(s)
+ for (let i = 0; i < raw.length; i++) {
+ if (raw[i] === 0x25 && i + 2 < raw.length) {
+ bytes.push(parseInt(String.fromCharCode(raw[i + 1]!, raw[i + 2]!), 16))
+ i += 2
+ } else {
+ bytes.push(raw[i]!)
+ }
+ }
+ return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes))
+}
+
+/**
+ * Go's `shouldEscape(c, encodeHost)` inverted: the bytes allowed unescaped in a
+ * host. Anything else (notably a space) makes `parseHost` fail.
+ */
+const HOST_ALLOWED = /^[A-Za-z0-9\-._~!$&'()*+,;=:[\]<>"%]$/
+
+const validOptionalPort = (colonPort: string): boolean =>
+ colonPort === "" || /^:[0-9]*$/.test(colonPort)
+
+/**
+ * `parseHost` — validates the port suffix and every host byte.
+ *
+ * The `%2e` rule is the subtle one: `unescape(..., encodeHost)` rejects any
+ * escape whose high nibble is `< 8` unless it is literally `%25`, because
+ * "hosts can't use %-encoding for ASCII bytes". So `//youtube%2ecom/@x` is a
+ * parse ERROR in Go and classifies as a keyword search, where a naive decoder
+ * would see `youtube.com` and call it a handle. Caught by differential fuzzing
+ * against Go 1.26.5 — 100 corpus rows hinged on it.
+ */
+const parseHost = (authority: string): string | undefined => {
+ if (authority.startsWith("[")) {
+ // A bracketed IP-literal: the port, if any, follows the closing bracket.
+ // Go additionally parses the address itself (rejecting `[v7.abc]`); that is
+ // not reproduced because no bracketed host can ever equal youtube.com, so
+ // it cannot change a classification.
+ const close = authority.lastIndexOf("]")
+ if (close < 0) return undefined
+ if (!validOptionalPort(authority.slice(close + 1))) return undefined
+ return authority
+ }
+ const colon = authority.lastIndexOf(":")
+ if (colon !== -1 && !validOptionalPort(authority.slice(colon))) return undefined
+ for (const char of authority) {
+ if (char === "%") continue
+ if (char.codePointAt(0)! >= 0x80) continue
+ if (!HOST_ALLOWED.test(char)) return undefined
+ }
+ if (!escapesValid(authority)) return undefined
+ for (let i = 0; i < authority.length; i++) {
+ if (authority[i] !== "%") continue
+ if (parseInt(authority[i + 1]!, 16) < 8 && authority.slice(i, i + 3) !== "%25") return undefined
+ i += 2
+ }
+ return percentDecode(authority)
+}
+
+/** `getScheme` — a leading alpha followed by alnum/`+`/`-`/`.` up to a `:`. */
+const getScheme = (raw: string): { scheme: string; rest: string } | undefined => {
+ for (let i = 0; i < raw.length; i++) {
+ const c = raw[i]!
+ if (/[A-Za-z]/.test(c)) continue
+ if (/[0-9+\-.]/.test(c)) {
+ if (i === 0) return { scheme: "", rest: raw }
+ continue
+ }
+ if (c === ":") {
+ if (i === 0) return undefined // "missing protocol scheme"
+ return { scheme: raw.slice(0, i), rest: raw.slice(i + 1) }
+ }
+ return { scheme: "", rest: raw }
+ }
+ return { scheme: "", rest: raw }
+}
+
+/**
+ * `url.Parse`, restricted to what the channel classifier observes: control-byte
+ * rejection, fragment/query splitting, scheme detection, the relative-path
+ * colon rule, `//authority` extraction with userinfo stripping, host
+ * validation, and percent-decoded paths.
+ *
+ * Returns `undefined` where Go returns an error — the classifier treats both
+ * identically (fall through to a keyword search).
+ */
+export const goUrlParse = (raw: string): GoUrl | undefined => {
+ const hash = raw.indexOf("#")
+ const beforeFragment = hash === -1 ? raw : raw.slice(0, hash)
+ const fragment = hash === -1 ? "" : raw.slice(hash + 1)
+
+ if (containsControl(beforeFragment)) return undefined
+
+ const scheme = getScheme(beforeFragment)
+ if (scheme === undefined) return undefined
+
+ // Go splits the query off before touching the authority, and never validates
+ // its escapes — `?q=%zz` parses fine.
+ const question = scheme.rest.indexOf("?")
+ let rest = question === -1 ? scheme.rest : scheme.rest.slice(0, question)
+
+ if (!rest.startsWith("/")) {
+ // A rootless path under a scheme is opaque: no host, no path.
+ if (scheme.scheme !== "") return { host: "", hostname: "", path: "" }
+ const firstSegment = rest.includes("/") ? rest.slice(0, rest.indexOf("/")) : rest
+ if (firstSegment.includes(":")) return undefined
+ }
+
+ let host = ""
+ if ((scheme.scheme !== "" || !rest.startsWith("///")) && rest.startsWith("//")) {
+ let authority = rest.slice(2)
+ rest = ""
+ const slash = authority.indexOf("/")
+ if (slash >= 0) {
+ rest = authority.slice(slash)
+ authority = authority.slice(0, slash)
+ }
+ const at = authority.lastIndexOf("@")
+ const parsed = parseHost(at < 0 ? authority : authority.slice(at + 1))
+ if (parsed === undefined) return undefined
+ host = parsed
+ }
+
+ if (!escapesValid(rest)) return undefined
+ if (fragment !== "" && !escapesValid(fragment)) return undefined
+
+ // Hostname(): drop a valid `:port`, then unwrap IPv6 brackets.
+ let hostname = host
+ const colon = hostname.lastIndexOf(":")
+ if (colon !== -1 && validOptionalPort(hostname.slice(colon))) hostname = hostname.slice(0, colon)
+ if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1)
+
+ return { host, hostname, path: percentDecode(rest) }
+}
+
+// ---------------------------------------------------------------------------
+// Classification
+// ---------------------------------------------------------------------------
+
+export type ChannelReferenceKind = "handle" | "id" | "username" | "search"
+
+export interface ChannelReference {
+ readonly kind: ChannelReferenceKind
+ readonly value: string
+}
+
+/** `strings.Trim(s, "/")` — strips ALL leading and trailing slashes. */
+const trimSlashes = (s: string): string => s.replace(/^\/+/, "").replace(/\/+$/, "")
+
+/**
+ * `parseChannelReference`. Note two deliberate Go quirks preserved verbatim:
+ *
+ * - `strings.TrimPrefix(hostname, "www.")` runs BEFORE `strings.ToLower`, so
+ * an uppercase `WWW.` is not stripped and the host test then fails.
+ * - the scheme is never checked, so `ftp://youtube.com/@x` classifies as a
+ * handle.
+ */
+export const parseChannelReference = (reference: string): ChannelReference => {
+ if (reference.startsWith("@")) return { kind: "handle", value: reference }
+
+ let candidate = reference
+ if (
+ !candidate.includes("://") &&
+ (candidate.includes("youtube.com/") || candidate.includes("youtu.be/"))
+ ) {
+ candidate = `https://${candidate}`
+ }
+
+ const parsed = goUrlParse(candidate)
+ if (parsed !== undefined && parsed.host !== "") {
+ const host = parsed.hostname.replace(/^www\./, "").toLowerCase()
+ if (host === "youtube.com" || host === "m.youtube.com") {
+ const parts = trimSlashes(parsed.path).split("/")
+ const first = parts[0]
+ if (first !== undefined && first.startsWith("@")) return { kind: "handle", value: first }
+ if (parts.length >= 2) {
+ const second = parts[1]!
+ if (first === "channel") return { kind: "id", value: second }
+ if (first === "user") return { kind: "username", value: second }
+ if (first === "c") return { kind: "search", value: second }
+ }
+ }
+ }
+
+ return { kind: "search", value: reference }
+}
+
+// ---------------------------------------------------------------------------
+// Resolution
+// ---------------------------------------------------------------------------
+
+/** The single-shot `Get` this module needs; injected to avoid a cycle. */
+export type GetResponse = (
+ resource: string,
+ params: Params
+) => Effect.Effect
+
+const firstItem = (response: DataApiResponse): JsonObject | undefined =>
+ // Safe: the decoded value came from parseJson, whose leaves are all JsonValue.
+ (response.items?.[0] as JsonObject | undefined) ?? undefined
+
+const notFound = (reference: string): NotFoundError =>
+ new NotFoundError({ message: `channel ${goQuote(reference)} not found` })
+
+/**
+ * `(channelID, requestsUsed)` where `requestsUsed` is 0 or 1.
+ *
+ * Go returns the partial request count alongside the error; every caller adds
+ * it and then discards the total by returning the error, so failing outright
+ * is equivalent.
+ */
+export const resolveChannelWith =
+ (get: GetResponse) =>
+ (reference: string): Effect.Effect =>
+ Effect.gen(function* () {
+ // Go's TrimSpace uses unicode.IsSpace, which includes U+0085 and U+00A0
+ // but excludes U+FEFF and U+200B. JS `trim` uses WhiteSpace + LineTerminator
+ // + U+FEFF — the only divergence is a BOM-padded reference, which JS
+ // trims and Go does not.
+ const trimmed = reference.trim()
+ if (trimmed === "") {
+ // Go uses errors.New here, not a UsageError, so this exits 6.
+ return yield* Effect.fail(
+ new OperationalError({ message: "channel reference cannot be empty" })
+ )
+ }
+ if (CHANNEL_ID_PATTERN.test(trimmed)) return { id: trimmed, requests: 0 }
+
+ const classified = parseChannelReference(trimmed)
+
+ if (classified.kind === "id") {
+ if (!CHANNEL_ID_PATTERN.test(classified.value)) {
+ return yield* Effect.fail(
+ new OperationalError({ message: `invalid channel ID ${goQuote(classified.value)}` })
+ )
+ }
+ return { id: classified.value, requests: 0 }
+ }
+
+ if (classified.kind === "search") {
+ const response = yield* get("search", [
+ ["part", "snippet"],
+ ["type", "channel"],
+ ["q", classified.value],
+ ["maxResults", "1"]
+ ])
+ const item = firstItem(response)
+ if (item === undefined) return yield* Effect.fail(notFound(trimmed))
+ // search returns an OBJECT-valued `id`, so the channel ID is nested.
+ const id = searchItemChannelId(item)
+ if (id._tag === "None" || id.value === "") return yield* Effect.fail(notFound(trimmed))
+ return { id: id.value, requests: 1 }
+ }
+
+ const params: Params =
+ classified.kind === "handle"
+ ? [
+ ["part", "id"],
+ ["forHandle", classified.value.replace(/^@/, "")]
+ ]
+ : [
+ ["part", "id"],
+ ["forUsername", classified.value]
+ ]
+
+ const response = yield* get("channels", params)
+ const item = firstItem(response)
+ if (item === undefined) return yield* Effect.fail(notFound(trimmed))
+ // channels returns a FLAT string `id`, unlike search.
+ const id = channelItemId(item)
+ if (id._tag === "None") return yield* Effect.fail(notFound(trimmed))
+ return { id: id.value, requests: 1 }
+ })
diff --git a/src/impl/semver.test.ts b/src/impl/semver.test.ts
new file mode 100644
index 0000000..5f7e731
--- /dev/null
+++ b/src/impl/semver.test.ts
@@ -0,0 +1,188 @@
+import { describe, expect, test } from "bun:test"
+import { compareVersions, goTrimSpace, parseVersion } from "./semver.ts"
+
+/** Port of Go's `TestCompareVersions` — the same nine rows, same order. */
+describe("TestCompareVersions", () => {
+ const cases: ReadonlyArray<{
+ readonly a: string
+ readonly b: string
+ readonly want: number
+ readonly comparable: boolean
+ }> = [
+ { a: "v1.2.3", b: "v1.2.3", want: 0, comparable: true },
+ { a: "v1.2.3", b: "1.2.3", want: 0, comparable: true },
+ { a: "v0.2.0", b: "v0.10.0", want: -1, comparable: true },
+ { a: "v2.0.0", b: "v1.9.9", want: 1, comparable: true },
+ { a: "v1.0.0-rc.1", b: "v1.0.0", want: -1, comparable: true },
+ { a: "v1.0.0", b: "v1.0.0-rc.1", want: 1, comparable: true },
+ { a: "v1.0.0-rc.1", b: "v1.0.0-rc.2", want: -1, comparable: true },
+ { a: "dev", b: "v1.0.0", want: 0, comparable: false },
+ { a: "v1.0.0", b: "unknown", want: 0, comparable: false }
+ ]
+
+ for (const { a, b, want, comparable } of cases) {
+ test(`compareVersions(${a}, ${b}) = ${want}, ${comparable}`, () => {
+ expect(compareVersions(a, b)).toEqual({ order: want as -1 | 0 | 1, comparable })
+ })
+ }
+})
+
+describe("byte-wise prerelease comparison", () => {
+ /**
+ * The behaviour the port MUST preserve: this is a plain string compare, not
+ * SemVer's dot-separated identifier compare. Under SemVer rc.10 > rc.2;
+ * here it is smaller, exactly as Go's `av.pre < bv.pre` decides.
+ */
+ test("rc.10 sorts BEFORE rc.2 (not SemVer ordering)", () => {
+ expect(compareVersions("v1.0.0-rc.10", "v1.0.0-rc.2")).toEqual({
+ order: -1,
+ comparable: true
+ })
+ expect(compareVersions("v1.0.0-rc.2", "v1.0.0-rc.10")).toEqual({
+ order: 1,
+ comparable: true
+ })
+ })
+
+ test("alpha < beta", () => {
+ expect(compareVersions("v1.0.0-alpha", "v1.0.0-beta").order).toBe(-1)
+ })
+
+ test("uppercase sorts before lowercase, as ASCII bytes do", () => {
+ expect(compareVersions("v1.0.0-RC.1", "v1.0.0-rc.1").order).toBe(-1)
+ })
+
+ test("identical prereleases are equal", () => {
+ expect(compareVersions("v1.0.0-rc.1", "1.0.0-rc.1")).toEqual({ order: 0, comparable: true })
+ })
+
+ test("a shorter prefix sorts first", () => {
+ expect(compareVersions("v1.0.0-rc", "v1.0.0-rc.1").order).toBe(-1)
+ })
+})
+
+describe("numeric ordering", () => {
+ test("compares major, then minor, then patch", () => {
+ expect(compareVersions("v1.0.0", "v0.99.99").order).toBe(1)
+ expect(compareVersions("v1.0.0", "v1.1.0").order).toBe(-1)
+ expect(compareVersions("v1.1.1", "v1.1.0").order).toBe(1)
+ })
+
+ test("numeric, not lexical: 10 > 9", () => {
+ expect(compareVersions("v0.10.0", "v0.9.0").order).toBe(1)
+ })
+
+ test("leading zeros are numeric values, as strconv.Atoi reads them", () => {
+ expect(compareVersions("v01.02.03", "v1.2.3")).toEqual({ order: 0, comparable: true })
+ })
+
+ test("counters beyond 2^32 still compare", () => {
+ expect(compareVersions("v4294967296.0.0", "v4294967295.0.0").order).toBe(1)
+ })
+})
+
+describe("parseVersion", () => {
+ test("accepts a bare or v-prefixed core", () => {
+ expect(parseVersion("1.2.3")).toEqual({ numbers: [1, 2, 3], prerelease: "" })
+ expect(parseVersion("v1.2.3")).toEqual({ numbers: [1, 2, 3], prerelease: "" })
+ })
+
+ test("strips surrounding whitespace", () => {
+ expect(parseVersion(" v1.2.3\t")).toEqual({ numbers: [1, 2, 3], prerelease: "" })
+ })
+
+ test("splits the prerelease at the FIRST dash only", () => {
+ expect(parseVersion("1.2.3-a-b")).toEqual({ numbers: [1, 2, 3], prerelease: "a-b" })
+ })
+
+ /**
+ * Verified against Go: `strings.Cut(core, "+")` runs on the CORE only, after
+ * the prerelease has already been split off, so build metadata attached to a
+ * prerelease stays part of the prerelease string.
+ */
+ test("build metadata is stripped from the core only", () => {
+ expect(parseVersion("1.2.3+build")).toEqual({ numbers: [1, 2, 3], prerelease: "" })
+ expect(parseVersion("1.2.3-rc.1+b")).toEqual({ numbers: [1, 2, 3], prerelease: "rc.1+b" })
+ })
+
+ test("a trailing dash yields an empty (release) prerelease", () => {
+ expect(parseVersion("1.2.3-")).toEqual({ numbers: [1, 2, 3], prerelease: "" })
+ })
+
+ test("rejects anything that is not exactly three dot-separated parts", () => {
+ for (const tag of ["1.2", "1.2.3.4", "1", "..", "1..3"]) {
+ expect(parseVersion(tag)).toBeUndefined()
+ }
+ })
+
+ test("rejects non-integer components, matching strconv.Atoi", () => {
+ for (const tag of ["1.2.x", "1.2.3e0", "1.2. 3", "1.2.0x3", "1.2.1_0", "1.2.٣"]) {
+ expect(parseVersion(tag)).toBeUndefined()
+ }
+ })
+
+ test("rejects negative components", () => {
+ expect(parseVersion("1.-2.3")).toBeUndefined()
+ })
+
+ /**
+ * Verified against Go: the build-metadata cut runs at the FIRST `+`, so a
+ * leading `+` empties the core and the tag is rejected before `strconv.Atoi`
+ * (which would otherwise have accepted `+1`) is ever reached.
+ */
+ test("a leading plus empties the core and is rejected", () => {
+ expect(parseVersion("+1.2.3")).toBeUndefined()
+ expect(parseVersion("1.+2.3")).toBeUndefined()
+ expect(parseVersion("1.2.+3")).toBeUndefined()
+ })
+
+ test("rejects values past int64, where Atoi reports out of range", () => {
+ expect(parseVersion("99999999999999999999.0.0")).toBeUndefined()
+ })
+
+ test("rejects the empty tag and a bare v", () => {
+ expect(parseVersion("")).toBeUndefined()
+ expect(parseVersion("v")).toBeUndefined()
+ expect(parseVersion(" ")).toBeUndefined()
+ })
+
+ test("a leading dash makes the core empty and unparseable", () => {
+ expect(parseVersion("-1.2.3")).toBeUndefined()
+ })
+
+ test("dev and unknown never parse, which is what keeps them incomparable", () => {
+ expect(parseVersion("dev")).toBeUndefined()
+ expect(parseVersion("unknown")).toBeUndefined()
+ })
+})
+
+describe("goTrimSpace", () => {
+ test("strips the Go whitespace set", () => {
+ expect(goTrimSpace("\t\n\v\f\r x \r\n")).toBe("x")
+ expect(goTrimSpace(" x ")).toBe("x")
+ expect(goTrimSpace("
x
")).toBe("x")
+ })
+
+ test("strips U+0085 (NEL), which JS trim() leaves in place", () => {
+ expect(goTrimSpace("
x
")).toBe("x")
+ })
+
+ test("does NOT strip U+FEFF, which JS trim() removes", () => {
+ expect(goTrimSpace("x")).toBe("x")
+ })
+})
+
+describe("incomparability is not equality", () => {
+ test("an unparseable current version reports comparable=false, not order=0", () => {
+ const result = compareVersions("v0.2.0", "dev")
+ expect(result.comparable).toBe(false)
+ // The updater keys off `comparable` before it looks at `order`, which is
+ // what makes a dev build always proceed to install rather than report
+ // itself up to date.
+ expect(result.order).toBe(0)
+ })
+
+ test("both sides unparseable", () => {
+ expect(compareVersions("dev", "dev")).toEqual({ order: 0, comparable: false })
+ })
+})
diff --git a/src/impl/semver.ts b/src/impl/semver.ts
index ca14f9a..80f7af6 100644
--- a/src/impl/semver.ts
+++ b/src/impl/semver.ts
@@ -1,2 +1,138 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * Version tag comparison — a faithful port of `internal/update/update.go`'s
+ * `parseVersion` / `CompareVersions`.
+ *
+ * This is deliberately **not** SemVer. Two behaviours are load-bearing and
+ * must not be "fixed":
+ *
+ * 1. The prerelease tie-break is a plain **byte-wise string comparison**,
+ * not SemVer's dot-separated identifier comparison. So `rc.10` sorts
+ * BEFORE `rc.2`. Changing this would silently change which release the
+ * updater considers newer. Go compares UTF-8 bytes, so `compareUtf8` is
+ * used rather than JS `<`, which compares UTF-16 code units and disagrees
+ * above the BMP.
+ * 2. Anything that does not parse makes the pair **incomparable**, not
+ * "equal". A `dev` build is therefore never up to date, which is exactly
+ * why an uninjected build always proceeds to install the latest release.
+ */
+
+import { compareUtf8 } from "../util/gostring.ts"
+
+/** Go's `unicode.IsSpace`, which is not the same set as JS `String.trim`. */
+const GO_SPACE = new Set([
+ 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20, 0x85, 0xa0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000
+])
+
+/**
+ * Go's `unicode.IsSpace`. Exported because `strings.Fields` needs the same
+ * predicate, and the JS `\s` class is not it: `\s` matches U+FEFF, which Go
+ * does not, and misses U+0085, which Go does.
+ */
+export const isGoSpace = (code: number): boolean =>
+ GO_SPACE.has(code) || (code >= 0x2000 && code <= 0x200a)
+
+/**
+ * `strings.TrimSpace`. JS `trim()` differs at both ends of the table: it does
+ * not strip U+0085 (NEL) and it does strip U+FEFF, neither of which matches Go.
+ */
+export const goTrimSpace = (value: string): string => {
+ let start = 0
+ let end = value.length
+ while (start < end && isGoSpace(value.charCodeAt(start))) start++
+ while (end > start && isGoSpace(value.charCodeAt(end - 1))) end--
+ return value.slice(start, end)
+}
+
+/** `strings.Cut(s, sep)` — split at the FIRST occurrence only. */
+const cut = (value: string, separator: string): readonly [string, string] => {
+ const index = value.indexOf(separator)
+ return index < 0 ? [value, ""] : [value.slice(0, index), value.slice(index + separator.length)]
+}
+
+/**
+ * `strconv.Atoi`, including its refusal of whitespace, underscores, decimal
+ * points and exponents, and its out-of-range failure past int64. Returns
+ * `undefined` where Go returns an error.
+ */
+const atoi = (text: string): number | undefined => {
+ if (!/^[+-]?[0-9]+$/.test(text)) return undefined
+ const value = BigInt(text)
+ // Go's `int` is 64-bit on every platform oytc ships for.
+ if (value > 9223372036854775807n || value < -9223372036854775808n) return undefined
+ return Number(value)
+}
+
+export interface ParsedVersion {
+ /** major, minor, patch. */
+ readonly numbers: readonly [number, number, number]
+ /** Prerelease text after the first `-`, or `""`. */
+ readonly prerelease: string
+}
+
+/**
+ * `parseVersion`. Returns `undefined` when the tag is not a `x.y.z` core with
+ * an optional `-prerelease` and optional `+build` metadata.
+ *
+ * Faithful oddities, verified against Go:
+ * - build metadata is stripped from the CORE only, so `1.2.3-rc.1+b` keeps a
+ * prerelease of `rc.1+b`;
+ * - a leading `+` does NOT parse: `strings.Cut(core, "+")` runs before
+ * `strconv.Atoi`, so `+1.2.3` empties the core and is rejected even though
+ * `Atoi("+1")` would have succeeded;
+ * - `01` is 1, and `1.2.3-` has an empty (i.e. release) prerelease.
+ */
+export const parseVersion = (tag: string): ParsedVersion | undefined => {
+ let text = goTrimSpace(tag)
+ if (text.startsWith("v")) text = text.slice(1)
+ if (text === "") return undefined
+
+ const [beforeDash, prerelease] = cut(text, "-")
+ const [core] = cut(beforeDash, "+")
+
+ const parts = core.split(".")
+ if (parts.length !== 3) return undefined
+
+ const numbers: Array = []
+ for (const part of parts) {
+ const value = atoi(part)
+ if (value === undefined || value < 0) return undefined
+ numbers.push(value)
+ }
+ return { numbers: [numbers[0]!, numbers[1]!, numbers[2]!], prerelease }
+}
+
+export type VersionOrder = -1 | 0 | 1
+
+export interface VersionComparison {
+ /** `-1` when a < b, `0` when equal, `1` when a > b. `0` when incomparable. */
+ readonly order: VersionOrder
+ /** False when either side failed to parse; `order` is then meaningless. */
+ readonly comparable: boolean
+}
+
+const INCOMPARABLE: VersionComparison = { order: 0, comparable: false }
+
+/**
+ * `CompareVersions(a, b)`. Compares major/minor/patch numerically, then
+ * tie-breaks on the prerelease: a release outranks any of its prereleases,
+ * and two prereleases compare **byte-wise as strings**.
+ */
+export const compareVersions = (a: string, b: string): VersionComparison => {
+ const left = parseVersion(a)
+ const right = parseVersion(b)
+ if (left === undefined || right === undefined) return INCOMPARABLE
+
+ for (let i = 0; i < 3; i++) {
+ const x = left.numbers[i]!
+ const y = right.numbers[i]!
+ if (x !== y) return { order: x < y ? -1 : 1, comparable: true }
+ }
+
+ if (left.prerelease === right.prerelease) return { order: 0, comparable: true }
+ if (left.prerelease === "") return { order: 1, comparable: true }
+ if (right.prerelease === "") return { order: -1, comparable: true }
+ return {
+ order: compareUtf8(left.prerelease, right.prerelease) < 0 ? -1 : 1,
+ comparable: true
+ }
+}
diff --git a/src/impl/skillInstaller.test.ts b/src/impl/skillInstaller.test.ts
new file mode 100644
index 0000000..3e778bf
--- /dev/null
+++ b/src/impl/skillInstaller.test.ts
@@ -0,0 +1,326 @@
+/**
+ * Ports `internal/skill/install_test.go` (2 cases).
+ *
+ * `TestInstallFSWritesAndReplacesCompleteSkill` substituted an `fstest.MapFS`
+ * for the embedded bundle. The TS bundle is a static import, so the real
+ * content is installed and the assertions check for content that must be
+ * present rather than for fixture strings — the two properties the Go test
+ * actually guards are unchanged and asserted verbatim:
+ *
+ * - a pre-existing `stale.md` inside the target does NOT survive the swap
+ * - no `.oytc-*-*` directory is left behind in the parent
+ *
+ * `TestBundledSkillIsComplete` checks the three embedded files are present
+ * and non-empty.
+ */
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import * as fsSync from "node:fs"
+import * as os from "node:os"
+import * as nodePath from "node:path"
+import { Cause, Effect, Exit, FileSystem, Layer, Path, PlatformError } from "effect"
+import { BunServices } from "@effect/platform-bun"
+import { bundledSkillFileNames, bundledSkillFiles } from "../skills/bundle.ts"
+import { installSkill, makeSkillInstaller } from "./skillInstaller.ts"
+import { ProcessEnv, type ProcessEnvShape } from "../services/index.ts"
+import { OperationalError } from "../domain/errors.ts"
+
+const run = (
+ effect: Effect.Effect
+): Promise => Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer)))
+
+let root: string
+
+beforeEach(() => {
+ root = fsSync.mkdtempSync(nodePath.join(os.tmpdir(), "oytc-skill-test-"))
+})
+
+afterEach(() => {
+ fsSync.rmSync(root, { recursive: true, force: true })
+})
+
+const targetPath = (): string => nodePath.join(root, ".agents", "skills", "oytc")
+
+/** The Go test's `filepath.Glob(dir, ".oytc-*-*")`. */
+const strayTempDirs = (parent: string): ReadonlyArray =>
+ fsSync.existsSync(parent)
+ ? fsSync
+ .readdirSync(parent)
+ .filter((name) => /^\.oytc-.*-.*$/.test(name))
+ .sort()
+ : []
+
+/** The `OperationalError.message` inside a failed `Exit`, or "" if absent. */
+const operationalMessage = (exit: Exit.Exit): string => {
+ if (exit._tag !== "Failure") return ""
+ const error = Cause.findErrorOption(exit.cause)
+ return error._tag === "Some" && error.value instanceof OperationalError ? error.value.message : ""
+}
+
+/**
+ * The real Bun filesystem with the staging swap sabotaged, so the rollback
+ * path in step 5 is actually executed rather than argued about.
+ */
+const failingRenameLayer = (): Layer.Layer =>
+ Layer.effect(
+ FileSystem.FileSystem,
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ return {
+ ...fs,
+ rename: (oldPath: string, newPath: string) =>
+ oldPath.includes(".oytc-install-")
+ ? Effect.fail(
+ PlatformError.systemError({
+ _tag: "PermissionDenied",
+ module: "FileSystem",
+ method: "rename",
+ pathOrDescriptor: newPath
+ })
+ )
+ : fs.rename(oldPath, newPath)
+ } satisfies FileSystem.FileSystem
+ })
+ ).pipe(Layer.provideMerge(BunServices.layer))
+
+describe("installSkill", () => {
+ // internal/skill/install_test.go: TestInstallFSWritesAndReplacesCompleteSkill
+ test("writes the complete skill and replaces an existing install wholesale", async () => {
+ const target = targetPath()
+ fsSync.mkdirSync(target, { recursive: true, mode: 0o755 })
+ fsSync.writeFileSync(nodePath.join(target, "stale.md"), "stale", { mode: 0o644 })
+
+ const result = await run(installSkill(target))
+ expect(result.path).toBe(target)
+ expect(result.files).toEqual(bundledSkillFileNames)
+
+ for (const file of bundledSkillFiles) {
+ const onDisk = fsSync.readFileSync(nodePath.join(target, ...file.name.split("/")), "utf8")
+ expect(onDisk).toBe(file.content)
+ }
+
+ // The whole point of the swap: user-added files are destroyed.
+ expect(fsSync.existsSync(nodePath.join(target, "stale.md"))).toBe(false)
+
+ // No staging or backup directory survives.
+ expect(strayTempDirs(nodePath.dirname(target))).toEqual([])
+ })
+
+ test("creates the skill from nothing when the target does not exist", async () => {
+ const target = targetPath()
+ expect(fsSync.existsSync(target)).toBe(false)
+
+ await run(installSkill(target))
+
+ for (const name of bundledSkillFileNames) {
+ expect(fsSync.existsSync(nodePath.join(target, ...name.split("/")))).toBe(true)
+ }
+ expect(strayTempDirs(nodePath.dirname(target))).toEqual([])
+ })
+
+ test("is idempotent — installing twice leaves the same tree and no temp dirs", async () => {
+ const target = targetPath()
+ await run(installSkill(target))
+ await run(installSkill(target))
+
+ expect(fsSync.readdirSync(target).sort()).toEqual(["SKILL.md", "references"])
+ expect(fsSync.readdirSync(nodePath.join(target, "references")).sort()).toEqual([
+ "commands.md",
+ "recipes.md"
+ ])
+ expect(strayTempDirs(nodePath.dirname(target))).toEqual([])
+ })
+
+ test("installs 0644 files inside 0755 directories, not 0600/0700", async () => {
+ const target = targetPath()
+ await run(installSkill(target))
+
+ const mode = (...segments: ReadonlyArray): number =>
+ fsSync.statSync(nodePath.join(target, ...segments)).mode & 0o777
+
+ expect(mode()).toBe(0o755)
+ expect(mode("references")).toBe(0o755)
+ expect(mode("SKILL.md")).toBe(0o644)
+ expect(mode("references", "commands.md")).toBe(0o644)
+ expect(mode("references", "recipes.md")).toBe(0o644)
+ })
+
+ test("a stale 0700/0600 install is re-hardened to 0755/0644 by the swap", async () => {
+ const target = targetPath()
+ fsSync.mkdirSync(target, { recursive: true, mode: 0o700 });
+ fsSync.chmodSync(target, 0o700)
+ fsSync.writeFileSync(nodePath.join(target, "SKILL.md"), "old", { mode: 0o600 })
+
+ await run(installSkill(target))
+
+ expect(fsSync.statSync(target).mode & 0o777).toBe(0o755)
+ expect(fsSync.statSync(nodePath.join(target, "SKILL.md")).mode & 0o777).toBe(0o644)
+ })
+
+ test("a restrictive umask does not leak into the installed tree", async () => {
+ // `mode` on create is masked by umask: under 077 the kernel would produce
+ // 0700/0600 and the skill would be unreadable by the other agents it exists
+ // to serve. Only the explicit chmods make the result stable.
+ const target = targetPath()
+ const previous = process.umask(0o077)
+ try {
+ await run(installSkill(target))
+ } finally {
+ process.umask(previous)
+ }
+
+ const mode = (...segments: ReadonlyArray): number =>
+ fsSync.statSync(nodePath.join(target, ...segments)).mode & 0o777
+
+ expect(mode()).toBe(0o755)
+ expect(mode("references")).toBe(0o755)
+ expect(mode("SKILL.md")).toBe(0o644)
+ expect(mode("references", "recipes.md")).toBe(0o644)
+ })
+
+ test("a DANGLING SYMLINK at the target is replaced, matching Go's Lstat", async () => {
+ // `stat` follows symlinks, so a broken link reports NotFound where Go's
+ // Lstat reported "exists". Skipping the backup branch would leave the link
+ // in place and `rename(stage, target)` would fail with ENOTDIR.
+ const parent = nodePath.join(root, ".agents", "skills")
+ fsSync.mkdirSync(parent, { recursive: true })
+ const target = nodePath.join(parent, "oytc")
+ fsSync.symlinkSync(nodePath.join(root, "nowhere-at-all"), target)
+
+ await run(installSkill(target))
+
+ expect(fsSync.lstatSync(target).isSymbolicLink()).toBe(false)
+ expect(fsSync.statSync(target).isDirectory()).toBe(true)
+ expect(fsSync.existsSync(nodePath.join(target, "SKILL.md"))).toBe(true)
+ expect(strayTempDirs(parent)).toEqual([])
+ })
+
+ test("creates every missing parent directory", async () => {
+ const target = nodePath.join(root, "deep", "nested", "chain", "oytc")
+ await run(installSkill(target))
+ expect(fsSync.existsSync(nodePath.join(target, "SKILL.md"))).toBe(true)
+ })
+
+ test("a plain FILE at the target is moved aside and replaced, not merged into", async () => {
+ // Go's Lstat succeeds on a file, so the backup/rename path runs and the
+ // file ends up deleted along with the backup.
+ const parent = nodePath.join(root, ".agents", "skills")
+ fsSync.mkdirSync(parent, { recursive: true })
+ const target = nodePath.join(parent, "oytc")
+ fsSync.writeFileSync(target, "not a directory")
+
+ await run(installSkill(target))
+
+ expect(fsSync.statSync(target).isDirectory()).toBe(true)
+ expect(fsSync.existsSync(nodePath.join(target, "SKILL.md"))).toBe(true)
+ expect(strayTempDirs(parent)).toEqual([])
+ })
+
+ test("rolls the previous install back and cleans up when the swap fails", async () => {
+ const target = targetPath()
+ // A complete previous install, so there is something to roll back to.
+ await run(installSkill(target))
+ fsSync.writeFileSync(nodePath.join(target, "marker.md"), "previous")
+
+ const exit = await Effect.runPromise(
+ installSkill(target).pipe(Effect.provide(failingRenameLayer()), Effect.exit)
+ )
+
+ expect(exit._tag).toBe("Failure")
+ expect(operationalMessage(exit)).toStartWith("install skill: ")
+
+ // The old install is back where it was, contents intact.
+ expect(fsSync.readFileSync(nodePath.join(target, "marker.md"), "utf8")).toBe("previous")
+ expect(fsSync.existsSync(nodePath.join(target, "SKILL.md"))).toBe(true)
+ expect(strayTempDirs(nodePath.dirname(target))).toEqual([])
+ })
+
+ test("wraps a filesystem failure in an OperationalError with Go's prefix", async () => {
+ // An unwritable parent makes step 1 (`MkdirAll`) fail.
+ const locked = nodePath.join(root, "locked")
+ fsSync.mkdirSync(locked, { mode: 0o500 })
+ const target = nodePath.join(locked, "sub", "oytc")
+
+ const exit = await Effect.runPromise(
+ installSkill(target).pipe(Effect.provide(BunServices.layer), Effect.exit)
+ )
+ expect(exit._tag).toBe("Failure")
+ expect(operationalMessage(exit)).toStartWith("create skills directory: ")
+ fsSync.chmodSync(locked, 0o700)
+ })
+})
+
+describe("bundled skill", () => {
+ // internal/skill/install_test.go: TestBundledSkillIsComplete
+ test("all three embedded files are present and non-empty", () => {
+ expect(bundledSkillFiles).toHaveLength(3)
+ for (const file of bundledSkillFiles) {
+ expect(file.content.length).toBeGreaterThan(0)
+ }
+ })
+
+ test("the file list is the hardcoded Go list, in order", () => {
+ expect(bundledSkillFileNames).toEqual([
+ "SKILL.md",
+ "references/commands.md",
+ "references/recipes.md"
+ ])
+ })
+
+ test("SKILL.md carries the frontmatter CI validates and the security clause", () => {
+ const skill = bundledSkillFiles[0]!.content
+ expect(skill).toStartWith("---\n")
+ expect(skill).toContain("name: oytc")
+ const description = /^description: (.+)$/m.exec(skill)?.[1]
+ expect(description).toBeDefined()
+ expect(description!.length).toBeGreaterThan(0)
+ expect(description!.length).toBeLessThan(1024)
+ expect(skill).toContain("Query public YouTube data")
+ expect(skill).toContain("Never print, log, or echo API keys")
+ })
+})
+
+describe("SkillInstaller service", () => {
+ const envLayer = (home: Effect.Effect) =>
+ Effect.provideService(ProcessEnv, {
+ env: () => ({ _tag: "None" }) as never,
+ platform: process.platform,
+ arch: process.arch,
+ argv: [],
+ executablePath: Effect.succeed("/bin/oytc"),
+ isOutputTTY: false,
+ homeDir: home
+ } satisfies ProcessEnvShape)
+
+ test("defaultPath is /.agents/skills/oytc", async () => {
+ const installer = await Effect.runPromise(
+ makeSkillInstaller.pipe(envLayer(Effect.succeed(root)), Effect.provide(BunServices.layer))
+ )
+ const path = await Effect.runPromise(installer.defaultPath)
+ expect(path).toBe(nodePath.join(root, ".agents", "skills", "oytc"))
+ })
+
+ test("defaultPath surfaces Go's 'find home directory' prefix", async () => {
+ const installer = await Effect.runPromise(
+ makeSkillInstaller.pipe(
+ envLayer(Effect.fail(new OperationalError({ message: "no home" }))),
+ Effect.provide(BunServices.layer)
+ )
+ )
+ const exit = await Effect.runPromise(installer.defaultPath.pipe(Effect.exit))
+ expect(exit._tag).toBe("Failure")
+ expect(operationalMessage(exit)).toBe("find home directory: no home")
+ })
+
+ test("install through the service writes the bundle", async () => {
+ const installer = await Effect.runPromise(
+ makeSkillInstaller.pipe(envLayer(Effect.succeed(root)), Effect.provide(BunServices.layer))
+ )
+ const target = targetPath()
+ const result = await Effect.runPromise(installer.install(target))
+ expect(result.path).toBe(target)
+ expect(fsSync.readFileSync(nodePath.join(target, "SKILL.md"), "utf8")).toBe(
+ bundledSkillFiles[0]!.content
+ )
+ })
+})
diff --git a/src/impl/skillInstaller.ts b/src/impl/skillInstaller.ts
index ca14f9a..edd4798 100644
--- a/src/impl/skillInstaller.ts
+++ b/src/impl/skillInstaller.ts
@@ -1,2 +1,206 @@
-/** STUB — implemented in a later package. */
-export {}
+/**
+ * `SkillInstaller` — the port of `internal/skill/install.go`.
+ *
+ * The install is an **atomic directory swap**, not a merge:
+ *
+ * 1. `mkdir -p` the parent at 0755.
+ * 2. Build the whole new skill in a staging dir `.oytc-install-*` created in
+ * that same parent (same filesystem, so the rename in step 5 is atomic).
+ * `chmod 0755` the staging dir — `mkdtemp` creates it 0700.
+ * 3. Write each of the three bundled files at 0644, creating `references/`
+ * at 0755. The list comes from `skills/bundle.ts` and is hardcoded.
+ * 4. If the target exists, reserve a free name by creating a second temp dir
+ * `.oytc-backup-*` and immediately removing it, then rename the existing
+ * install onto that reserved name.
+ * 5. Rename staging -> target. On failure, roll the backup back into place.
+ * 6. Delete the backup.
+ *
+ * Consequences worth stating plainly, because they are load-bearing:
+ *
+ * - **Any user-added file under the target is destroyed.** Go's test asserts
+ * exactly this with a `stale.md` that must not survive. It is a replacement,
+ * not a merge, so a reader never observes a half-written skill.
+ * - **No `.oytc-*-*` directory may survive.** Go used `defer os.RemoveAll(stage)`
+ * on every path; here `Effect.onExit` does the same job, and it runs on
+ * interruption as well as on failure. The backup name is only ever a
+ * directory between steps 4 and 6.
+ *
+ * Permissions are 0755/0644 and NOT the 0700/0600 of the credential file: the
+ * skill is non-secret content meant to be read by other agents.
+ */
+
+import { Effect, FileSystem, Layer, Path } from "effect"
+import { OperationalError } from "../domain/errors.ts"
+import {
+ ProcessEnv,
+ SkillInstaller,
+ type ProcessEnvShape,
+ type SkillInstallerShape,
+ type SkillInstallResult
+} from "../services/index.ts"
+import { bundledSkillFileNames, bundledSkillFiles } from "../skills/bundle.ts"
+
+const DIRECTORY_MODE = 0o755
+const FILE_MODE = 0o644
+
+/** Go rendered `%w` as the wrapped error's message; PlatformError's is close enough. */
+const describe = (cause: unknown): string =>
+ cause instanceof Error ? cause.message : String(cause)
+
+const fail = (message: string) => (cause: unknown) =>
+ Effect.fail(new OperationalError({ message: `${message}: ${describe(cause)}`, cause }))
+
+/** Ignore every failure, including defects — used for the temp-dir cleanups. */
+const bestEffort = (effect: Effect.Effect): Effect.Effect =>
+ Effect.ignoreCause(effect)
+
+/**
+ * Whether `target` exists, following Go's `os.Lstat` + `os.IsNotExist` split:
+ * a "not found" is `false`, and any *other* stat failure is fatal.
+ *
+ * Effect's `FileSystem` has no `lstat`, and `stat` follows symlinks — so a
+ * **dangling symlink** at the target would report "not found" where Go's
+ * `Lstat` reported "exists". That is not benign: skipping the backup branch
+ * leaves the broken link in place, and `rename(stage, target)` onto an
+ * existing symlink-to-nowhere fails with ENOTDIR (verified on darwin). The
+ * install would break instead of replacing the link.
+ *
+ * `readLink` succeeding is exactly "the path is a symlink", which is the one
+ * case `stat` misses, so the two together reconstruct `Lstat`'s answer.
+ */
+const targetExists = (
+ fs: FileSystem.FileSystem,
+ target: string
+): Effect.Effect =>
+ fs.stat(target).pipe(
+ Effect.as(true),
+ Effect.catch((error) =>
+ error.reason._tag === "NotFound"
+ ? // Either genuinely absent, or a symlink whose target is absent.
+ fs.readLink(target).pipe(
+ Effect.as(true),
+ Effect.catchCause(() => Effect.succeed(false))
+ )
+ : fail("inspect existing skill")(error)
+ )
+ )
+
+/**
+ * Install the bundled skill into `target`, replacing whatever is there.
+ *
+ * Exported separately from the service so tests can drive it directly, the
+ * way Go's tests called the unexported `installFS`.
+ */
+export const installSkill = (
+ target: string
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+ const parent = path.dirname(target)
+
+ yield* fs
+ .makeDirectory(parent, { recursive: true, mode: DIRECTORY_MODE })
+ .pipe(Effect.catch(fail("create skills directory")))
+
+ const stage = yield* fs
+ .makeTempDirectory({ directory: parent, prefix: ".oytc-install-" })
+ .pipe(Effect.catch(fail("stage skill installation")))
+
+ // Everything below runs under a guaranteed cleanup of the staging dir —
+ // Go's `defer os.RemoveAll(stage)`. After a successful swap nothing exists
+ // under that name any more, so the removal is a no-op.
+ const install = Effect.gen(function* () {
+ // mkdtemp creates 0700; the installed skill directory must be 0755.
+ yield* fs.chmod(stage, DIRECTORY_MODE).pipe(Effect.catch(fail("stage skill installation")))
+
+ for (const file of bundledSkillFiles) {
+ const destination = path.join(stage, ...file.name.split("/"))
+ const directory = path.dirname(destination)
+ yield* fs
+ .makeDirectory(directory, { recursive: true, mode: DIRECTORY_MODE })
+ .pipe(Effect.catch(fail("create skill references directory")))
+ // `mode` on create is masked by umask (022 happens to yield 0755/0644,
+ // but 077 would give 0700/0600). Go had the same hole and shipped it;
+ // the explicit chmod makes the installed tree stable regardless, which
+ // matters because these files exist to be read by *other* agents.
+ if (directory !== stage) {
+ yield* fs
+ .chmod(directory, DIRECTORY_MODE)
+ .pipe(Effect.catch(fail("create skill references directory")))
+ }
+ yield* fs
+ .writeFileString(destination, file.content, { mode: FILE_MODE })
+ .pipe(Effect.catch(fail(`write ${file.name}`)))
+ yield* fs.chmod(destination, FILE_MODE).pipe(Effect.catch(fail(`write ${file.name}`)))
+ }
+
+ // Reserve a free name for the outgoing install: create a temp dir, then
+ // remove it so only the *name* is held. Go did exactly this.
+ const exists = yield* targetExists(fs, target)
+ const backup = exists
+ ? yield* fs.makeTempDirectory({ directory: parent, prefix: ".oytc-backup-" }).pipe(
+ Effect.catch(fail("prepare existing skill backup")),
+ Effect.tap((directory) =>
+ fs
+ .remove(directory, { recursive: true })
+ .pipe(Effect.catch(fail("prepare existing skill backup")))
+ )
+ )
+ : undefined
+
+ if (backup !== undefined) {
+ yield* fs.rename(target, backup).pipe(Effect.catch(fail("move existing skill aside")))
+ }
+
+ yield* fs.rename(stage, target).pipe(
+ Effect.catch((error) =>
+ Effect.gen(function* () {
+ // Roll back: put the old install back where it was. Go ignored the
+ // rollback's own error and reported the swap failure.
+ if (backup !== undefined) yield* bestEffort(fs.rename(backup, target))
+ return yield* fail("install skill")(error)
+ })
+ )
+ )
+
+ if (backup !== undefined) {
+ yield* fs
+ .remove(backup, { recursive: true })
+ .pipe(Effect.catch(fail("remove replaced skill")))
+ }
+
+ return { path: target, files: bundledSkillFileNames } satisfies SkillInstallResult
+ })
+
+ return yield* install.pipe(
+ Effect.onExit(() => bestEffort(fs.remove(stage, { recursive: true, force: true })))
+ )
+ })
+
+export const makeSkillInstaller: Effect.Effect<
+ SkillInstallerShape,
+ never,
+ ProcessEnvShape | FileSystem.FileSystem | Path.Path
+> = Effect.gen(function* () {
+ const env = yield* ProcessEnv
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+
+ return {
+ /** Go's `DefaultPath()`: `/.agents/skills/oytc`. There is no env override. */
+ defaultPath: env.homeDir.pipe(
+ Effect.map((home) => path.join(home, ".agents", "skills", "oytc")),
+ Effect.mapError(
+ (error) => new OperationalError({ message: `find home directory: ${error.message}` })
+ )
+ ),
+ install: (target) =>
+ installSkill(target).pipe(
+ Effect.provideService(FileSystem.FileSystem, fs),
+ Effect.provideService(Path.Path, path)
+ )
+ } satisfies SkillInstallerShape
+})
+
+export const SkillInstallerLive = Layer.effect(SkillInstaller, makeSkillInstaller)
diff --git a/src/impl/tokenSource.test.ts b/src/impl/tokenSource.test.ts
new file mode 100644
index 0000000..ee82c42
--- /dev/null
+++ b/src/impl/tokenSource.test.ts
@@ -0,0 +1,196 @@
+/**
+ * Ports `TestTokenSourceRefreshAndOnUpdate` plus the skew/persistence/re-hint
+ * behavior the Go code specifies but does not directly test.
+ */
+
+import { describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+// `effect/testing` is a stable subpath, so the src/effect.ts barrel rule does not
+// apply to it (that rule covers the unstable subpath only).
+import { TestClock } from "effect/testing"
+import { OAuthError, OperationalError } from "../domain/errors.ts"
+import { ExpiredAuthorizationError, type OAuthToken } from "./oauth.ts"
+import { EXPIRY_SKEW_MILLIS, makeTokenSource } from "./tokenSource.ts"
+
+const NOW = Date.UTC(2026, 0, 1, 0, 0, 0)
+
+const token = (overrides: Partial = {}): OAuthToken => ({
+ accessToken: "old-access",
+ refreshToken: "refresh",
+ expiryMillis: NOW + 3_600_000,
+ scopes: ["scope"],
+ ...overrides
+})
+
+/**
+ * Runs against a controlled clock pinned to NOW, so the 1-minute skew window is
+ * exercised at exact boundaries rather than by sleeping.
+ */
+const atNow =