@ratio-app/cli 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +504 -0
  2. package/bin/run.mjs +5 -0
  3. package/dist/commands/__test__/throw.d.ts +25 -0
  4. package/dist/commands/__test__/throw.js +238 -0
  5. package/dist/commands/__test__/throw.js.map +1 -0
  6. package/dist/commands/app/create.d.ts +55 -0
  7. package/dist/commands/app/create.js +609 -0
  8. package/dist/commands/app/create.js.map +1 -0
  9. package/dist/commands/app/deploy.d.ts +14 -0
  10. package/dist/commands/app/deploy.js +179 -0
  11. package/dist/commands/app/deploy.js.map +1 -0
  12. package/dist/commands/app/dev.d.ts +28 -0
  13. package/dist/commands/app/dev.js +701 -0
  14. package/dist/commands/app/dev.js.map +1 -0
  15. package/dist/commands/app/generate.d.ts +14 -0
  16. package/dist/commands/app/generate.js +179 -0
  17. package/dist/commands/app/generate.js.map +1 -0
  18. package/dist/commands/app/info.d.ts +14 -0
  19. package/dist/commands/app/info.js +179 -0
  20. package/dist/commands/app/info.js.map +1 -0
  21. package/dist/commands/app/link.d.ts +118 -0
  22. package/dist/commands/app/link.js +1120 -0
  23. package/dist/commands/app/link.js.map +1 -0
  24. package/dist/commands/auth/login.d.ts +43 -0
  25. package/dist/commands/auth/login.js +987 -0
  26. package/dist/commands/auth/login.js.map +1 -0
  27. package/dist/commands/auth/logout.d.ts +15 -0
  28. package/dist/commands/auth/logout.js +486 -0
  29. package/dist/commands/auth/logout.js.map +1 -0
  30. package/dist/commands/auth/whoami.d.ts +16 -0
  31. package/dist/commands/auth/whoami.js +531 -0
  32. package/dist/commands/auth/whoami.js.map +1 -0
  33. package/dist/commands/dev/listen.d.ts +84 -0
  34. package/dist/commands/dev/listen.js +1187 -0
  35. package/dist/commands/dev/listen.js.map +1 -0
  36. package/dist/commands/dev/trigger.d.ts +88 -0
  37. package/dist/commands/dev/trigger.js +729 -0
  38. package/dist/commands/dev/trigger.js.map +1 -0
  39. package/dist/index.d.ts +14 -0
  40. package/dist/index.js +617 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/lib/auth/authorize-code-exchange.d.ts +50 -0
  43. package/dist/lib/auth/authorize-code-exchange.js +114 -0
  44. package/dist/lib/auth/authorize-code-exchange.js.map +1 -0
  45. package/dist/lib/auth/authorize-url.d.ts +42 -0
  46. package/dist/lib/auth/authorize-url.js +35 -0
  47. package/dist/lib/auth/authorize-url.js.map +1 -0
  48. package/dist/lib/auth/browser.d.ts +16 -0
  49. package/dist/lib/auth/browser.js +48 -0
  50. package/dist/lib/auth/browser.js.map +1 -0
  51. package/dist/lib/auth/config.d.ts +41 -0
  52. package/dist/lib/auth/config.js +21 -0
  53. package/dist/lib/auth/config.js.map +1 -0
  54. package/dist/lib/auth/http-refresher.d.ts +30 -0
  55. package/dist/lib/auth/http-refresher.js +103 -0
  56. package/dist/lib/auth/http-refresher.js.map +1 -0
  57. package/dist/lib/auth/identity.d.ts +26 -0
  58. package/dist/lib/auth/identity.js +10 -0
  59. package/dist/lib/auth/identity.js.map +1 -0
  60. package/dist/lib/auth/index.d.ts +13 -0
  61. package/dist/lib/auth/index.js +527 -0
  62. package/dist/lib/auth/index.js.map +1 -0
  63. package/dist/lib/auth/loopback-server.d.ts +65 -0
  64. package/dist/lib/auth/loopback-server.js +245 -0
  65. package/dist/lib/auth/loopback-server.js.map +1 -0
  66. package/dist/lib/auth/manual-prompt.d.ts +17 -0
  67. package/dist/lib/auth/manual-prompt.js +20 -0
  68. package/dist/lib/auth/manual-prompt.js.map +1 -0
  69. package/dist/lib/auth/pkce.d.ts +24 -0
  70. package/dist/lib/auth/pkce.js +15 -0
  71. package/dist/lib/auth/pkce.js.map +1 -0
  72. package/dist/lib/auth/state.d.ts +20 -0
  73. package/dist/lib/auth/state.js +19 -0
  74. package/dist/lib/auth/state.js.map +1 -0
  75. package/dist/lib/auth/verbose-trace.d.ts +25 -0
  76. package/dist/lib/auth/verbose-trace.js +17 -0
  77. package/dist/lib/auth/verbose-trace.js.map +1 -0
  78. package/dist/lib/credentials/clock.d.ts +18 -0
  79. package/dist/lib/credentials/clock.js +10 -0
  80. package/dist/lib/credentials/clock.js.map +1 -0
  81. package/dist/lib/credentials/index.d.ts +5 -0
  82. package/dist/lib/credentials/index.js +340 -0
  83. package/dist/lib/credentials/index.js.map +1 -0
  84. package/dist/lib/credentials/path.d.ts +26 -0
  85. package/dist/lib/credentials/path.js +32 -0
  86. package/dist/lib/credentials/path.js.map +1 -0
  87. package/dist/lib/credentials/refresher.d.ts +26 -0
  88. package/dist/lib/credentials/refresher.js +34 -0
  89. package/dist/lib/credentials/refresher.js.map +1 -0
  90. package/dist/lib/credentials/schema.d.ts +138 -0
  91. package/dist/lib/credentials/schema.js +85 -0
  92. package/dist/lib/credentials/schema.js.map +1 -0
  93. package/dist/lib/credentials/store.d.ts +102 -0
  94. package/dist/lib/credentials/store.js +337 -0
  95. package/dist/lib/credentials/store.js.map +1 -0
  96. package/dist/lib/credentials/types.d.ts +65 -0
  97. package/dist/lib/credentials/types.js +1 -0
  98. package/dist/lib/credentials/types.js.map +1 -0
  99. package/dist/lib/dev-store-gate.d.ts +66 -0
  100. package/dist/lib/dev-store-gate.js +15 -0
  101. package/dist/lib/dev-store-gate.js.map +1 -0
  102. package/dist/lib/errors.d.ts +60 -0
  103. package/dist/lib/errors.js +101 -0
  104. package/dist/lib/errors.js.map +1 -0
  105. package/dist/lib/manifest/index.d.ts +2 -0
  106. package/dist/lib/manifest/index.js +144 -0
  107. package/dist/lib/manifest/index.js.map +1 -0
  108. package/dist/lib/manifest/read-manifest.d.ts +32 -0
  109. package/dist/lib/manifest/read-manifest.js +87 -0
  110. package/dist/lib/manifest/read-manifest.js.map +1 -0
  111. package/dist/lib/manifest/write-client-id.d.ts +33 -0
  112. package/dist/lib/manifest/write-client-id.js +94 -0
  113. package/dist/lib/manifest/write-client-id.js.map +1 -0
  114. package/dist/lib/messages.d.ts +15 -0
  115. package/dist/lib/messages.js +16 -0
  116. package/dist/lib/messages.js.map +1 -0
  117. package/dist/lib/orchestrator/child-runner.d.ts +140 -0
  118. package/dist/lib/orchestrator/child-runner.js +471 -0
  119. package/dist/lib/orchestrator/child-runner.js.map +1 -0
  120. package/dist/lib/preview/index.d.ts +11 -0
  121. package/dist/lib/preview/index.js +17 -0
  122. package/dist/lib/preview/index.js.map +1 -0
  123. package/dist/lib/preview/types.d.ts +16 -0
  124. package/dist/lib/preview/types.js +11 -0
  125. package/dist/lib/preview/types.js.map +1 -0
  126. package/dist/lib/ratio-command.d.ts +52 -0
  127. package/dist/lib/ratio-command.js +141 -0
  128. package/dist/lib/ratio-command.js.map +1 -0
  129. package/dist/lib/relay/index.d.ts +7 -0
  130. package/dist/lib/relay/index.js +362 -0
  131. package/dist/lib/relay/index.js.map +1 -0
  132. package/dist/lib/relay/relay-client.d.ts +91 -0
  133. package/dist/lib/relay/relay-client.js +362 -0
  134. package/dist/lib/relay/relay-client.js.map +1 -0
  135. package/dist/lib/relay/types.d.ts +71 -0
  136. package/dist/lib/relay/types.js +1 -0
  137. package/dist/lib/relay/types.js.map +1 -0
  138. package/dist/lib/render-error.d.ts +35 -0
  139. package/dist/lib/render-error.js +115 -0
  140. package/dist/lib/render-error.js.map +1 -0
  141. package/dist/lib/verbose-flag.d.ts +12 -0
  142. package/dist/lib/verbose-flag.js +17 -0
  143. package/dist/lib/verbose-flag.js.map +1 -0
  144. package/dist/render-CrHGqTPH.d.ts +115 -0
  145. package/dist/templates/.gitkeep +0 -0
  146. package/dist/templates/minimal/.env.example.tmpl +4 -0
  147. package/dist/templates/minimal/README.md.tmpl +26 -0
  148. package/dist/templates/minimal/gitignore +5 -0
  149. package/dist/templates/minimal/manifest.json +5 -0
  150. package/dist/templates/minimal/package.json.tmpl +25 -0
  151. package/dist/templates/minimal/ratio.config.jsonc.tmpl +17 -0
  152. package/dist/templates/minimal/src/index.ts.tmpl +26 -0
  153. package/dist/templates/minimal/src/install.ts.tmpl +37 -0
  154. package/dist/templates/minimal/src/webhooks.ts.tmpl +57 -0
  155. package/dist/templates/minimal/tsconfig.json +17 -0
  156. package/dist/templates/serverless/README.md.tmpl +28 -0
  157. package/dist/templates/serverless/gitignore +6 -0
  158. package/dist/templates/serverless/manifest.json +5 -0
  159. package/dist/templates/serverless/package.json.tmpl +22 -0
  160. package/dist/templates/serverless/ratio.config.jsonc.tmpl +17 -0
  161. package/dist/templates/serverless/src/index.ts.tmpl +20 -0
  162. package/dist/templates/serverless/src/install.ts.tmpl +20 -0
  163. package/dist/templates/serverless/src/webhooks.ts.tmpl +36 -0
  164. package/dist/templates/serverless/tsconfig.json +18 -0
  165. package/dist/templates/serverless/wrangler.toml.tmpl +9 -0
  166. package/dist/templates/with-admin-ui/.env.example.tmpl +4 -0
  167. package/dist/templates/with-admin-ui/README.md.tmpl +28 -0
  168. package/dist/templates/with-admin-ui/gitignore +6 -0
  169. package/dist/templates/with-admin-ui/manifest.json +5 -0
  170. package/dist/templates/with-admin-ui/package.json.tmpl +31 -0
  171. package/dist/templates/with-admin-ui/ratio.config.jsonc.tmpl +17 -0
  172. package/dist/templates/with-admin-ui/src/admin/app/globals.css +19 -0
  173. package/dist/templates/with-admin-ui/src/admin/app/layout.tsx.tmpl +13 -0
  174. package/dist/templates/with-admin-ui/src/admin/app/page.tsx.tmpl +8 -0
  175. package/dist/templates/with-admin-ui/src/admin/next-env.d.ts.tmpl +5 -0
  176. package/dist/templates/with-admin-ui/src/admin/next.config.mjs +3 -0
  177. package/dist/templates/with-admin-ui/src/admin/tsconfig.json +20 -0
  178. package/dist/templates/with-admin-ui/src/index.ts.tmpl +26 -0
  179. package/dist/templates/with-admin-ui/src/install.ts.tmpl +37 -0
  180. package/dist/templates/with-admin-ui/src/webhooks.ts.tmpl +57 -0
  181. package/dist/templates/with-admin-ui/tsconfig.json +17 -0
  182. package/package.json +78 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/dev-store-gate.ts","../src/lib/credentials/clock.ts","../src/lib/credentials/path.ts","../src/lib/errors.ts","../src/lib/credentials/refresher.ts","../src/lib/credentials/store.ts","../src/lib/credentials/schema.ts","../src/scaffold/render.ts","../src/index.ts"],"sourcesContent":["/**\n * Extension seam for future development-store gating.\n *\n * The Ratio platform has a forthcoming capability that will restrict certain\n * operations to development stores only. Today the CLI does not enforce any\n * such restriction — every operation is permitted regardless of store type.\n * When the platform gains that capability, one class swap in this module\n * (returning a different implementation from {@link getDevStoreGate}) will\n * activate enforcement without touching any consumer.\n *\n * Developers integrating with the CLI's internals do not need to interact\n * with this seam in v1.\n *\n * Rules:\n * - No imports from node:fs, node:net, node:http, db, fetch, or any other\n * I/O module. This module must remain pure/sync.\n * - No Date.now() / new Date() / Math.random().\n * - The only mutable module-level state is the singleton slot below.\n */\n\n// ---------------------------------------------------------------------------\n// Public interface\n// ---------------------------------------------------------------------------\n\n/**\n * Context passed by a caller invoking a would-be-gated operation.\n *\n * `command` is the only required field so that the seam is callable from\n * unauthenticated flows (e.g. login) as well as authenticated ones. `appId`\n * and `developerId` are optional because they are not yet known at every\n * potential call-site.\n */\nexport interface DevStoreGateContext {\n /** The invoking oclif command id (e.g. \"app link\"). Required. */\n readonly command: string;\n /** The current app being operated on, if any. */\n readonly appId?: string;\n /** The current developer, if authenticated. */\n readonly developerId?: string;\n}\n\n/** Extension point for future development-store gating enforcement. */\nexport interface DevStoreGate {\n /**\n * Assert the current context is allowed to invoke a gated operation.\n *\n * v1 implementations MAY be a no-op. Future implementations will throw\n * when the operation is not permitted for the current context. Callers\n * cannot silently ignore a denial: the method returns `void`, so the\n * only signalling channel is an exception.\n */\n assertAllowed(context: DevStoreGateContext): void;\n}\n\n// ---------------------------------------------------------------------------\n// NoopDevStoreGate — v1 no-op implementation (shipping default)\n// ---------------------------------------------------------------------------\n\n/**\n * v1 no-op implementation. Permits every operation unconditionally by\n * simply returning. Replaced by a future implementation when the platform\n * capability lights up.\n */\nexport class NoopDevStoreGate implements DevStoreGate {\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n public assertAllowed(_context: DevStoreGateContext): void {\n // No-op by design. See module JSDoc.\n }\n}\n\n// ---------------------------------------------------------------------------\n// Module-level singleton factory\n// ---------------------------------------------------------------------------\n\n// Stateless — a single instance is sound for the whole process lifetime.\nconst _defaultInstance: DevStoreGate = new NoopDevStoreGate();\n\n/**\n * Return the current {@link DevStoreGate} binding.\n *\n * v1 returns a singleton {@link NoopDevStoreGate}. When the platform\n * capability lights up, this factory is the single point that will\n * return a different implementation. No other code path is expected\n * to instantiate a {@link DevStoreGate}.\n */\nexport function getDevStoreGate(): DevStoreGate {\n return _defaultInstance;\n}\n","/**\n * Injectable wall-clock seam for the credential store.\n *\n * This is the ONLY file in the credentials module allowed to sample the\n * real wall clock. Every other file receives a {@link Clock} instance so\n * tests can drive expiry-window logic deterministically with a fake.\n */\n\n/** Injectable time source. */\nexport interface Clock {\n /** Milliseconds since Unix epoch. */\n now(): number;\n}\n\n/** Production {@link Clock} backed by the system wall clock. */\nexport class SystemClock implements Clock {\n now(): number {\n return Date.now();\n }\n}\n","/**\n * Pure path resolution for the credentials file.\n *\n * POSIX: `$XDG_CONFIG_HOME/ratio/credentials` when the override is a\n * non-empty string, otherwise `~/.config/ratio/credentials`.\n * Windows: `%APPDATA%\\ratio\\credentials`, falling back to\n * `<home>\\AppData\\Roaming\\ratio\\credentials` when APPDATA is unset.\n *\n * `path.win32.join` / `path.posix.join` are used explicitly (never bare\n * `path.join`) so the result is deterministic when a test running on a\n * POSIX host fakes `platform === 'win32'`.\n */\n\nimport os from 'node:os';\nimport path from 'node:path';\n\n/** The subset of environment variables the path algorithm consults. */\nexport interface PathEnv {\n readonly XDG_CONFIG_HOME?: string;\n readonly APPDATA?: string;\n}\n\n/** Snapshot the two relevant variables from the real process env. */\nfunction snapshotPathEnv(): PathEnv {\n const snapshot: { XDG_CONFIG_HOME?: string; APPDATA?: string } = {};\n const xdgConfigHome = process.env['XDG_CONFIG_HOME'];\n if (xdgConfigHome !== undefined) {\n snapshot.XDG_CONFIG_HOME = xdgConfigHome;\n }\n const appData = process.env['APPDATA'];\n if (appData !== undefined) {\n snapshot.APPDATA = appData;\n }\n return snapshot;\n}\n\n/**\n * Pure function. Returns the on-disk path for the credentials file.\n * platform defaults to process.platform, homedir defaults to os.homedir(),\n * env defaults to a snapshot of {XDG_CONFIG_HOME, APPDATA} from process.env.\n * All three are injectable so unit tests fake Windows without a Windows host.\n */\nexport function defaultCredentialsPath(\n platform?: NodeJS.Platform,\n homedir?: string,\n env?: PathEnv,\n): string {\n const resolvedPlatform = platform ?? process.platform;\n const resolvedHome = homedir ?? os.homedir();\n const resolvedEnv = env ?? snapshotPathEnv();\n\n if (resolvedPlatform === 'win32') {\n const appData = resolvedEnv.APPDATA;\n const base =\n appData !== undefined && appData !== ''\n ? appData\n : path.win32.join(resolvedHome, 'AppData', 'Roaming');\n return path.win32.join(base, 'ratio', 'credentials');\n }\n\n const xdgConfigHome = resolvedEnv.XDG_CONFIG_HOME;\n const base =\n typeof xdgConfigHome === 'string' && xdgConfigHome !== ''\n ? xdgConfigHome\n : path.posix.join(resolvedHome, '.config');\n return path.posix.join(base, 'ratio', 'credentials');\n}\n","/**\n * Error hierarchy for the Ratio CLI. Every error thrown from a command's\n * run() is expected to be an instance of RatioCLIError so the global\n * render-error handler can map it to a deterministic stderr line and exit\n * code. Uncaught Error subclasses render as \"INTERNAL: <msg>\" with exit 1.\n *\n * No import from @oclif/core — this file is dependency-free so tests\n * can import it without oclif overhead.\n */\n\nexport class RatioCLIError extends Error {\n readonly code: string;\n readonly exitCode: number;\n readonly hint?: string;\n readonly docsUrl?: string;\n\n constructor(opts: {\n code: string;\n exitCode: number;\n message: string;\n hint?: string;\n docsUrl?: string;\n }) {\n super(opts.message);\n this.name = new.target.name;\n this.code = opts.code;\n this.exitCode = opts.exitCode;\n if (opts.hint !== undefined) this.hint = opts.hint;\n if (opts.docsUrl !== undefined) this.docsUrl = opts.docsUrl;\n }\n}\n\n/** Coming-soon stub UX. Renders on stdout with exit 0. */\nexport class ComingSoonError extends RatioCLIError {\n constructor(message: string) {\n super({ code: 'COMING_SOON', exitCode: 0, message });\n }\n}\n\n/** User has no credentials and command requires auth. */\nexport class NotLoggedInError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Run 'ratio auth login' to sign in.\",\n ) {\n super({ code: 'NOT_LOGGED_IN', exitCode: 3, message, hint });\n }\n}\n\nexport class TokenExpiredError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Run 'ratio auth login' to refresh your session.\",\n ) {\n super({ code: 'TOKEN_EXPIRED', exitCode: 3, message, hint });\n }\n}\n\nexport class ScopeDeniedError extends RatioCLIError {\n constructor(message: string, hint?: string) {\n const opts: { code: string; exitCode: number; message: string; hint?: string } = {\n code: 'SCOPE_DENIED',\n exitCode: 4,\n message,\n };\n if (hint !== undefined) opts.hint = hint;\n super(opts);\n }\n}\n\nexport class NetworkError extends RatioCLIError {\n constructor(message: string, hint: string = 'Check your network and retry.') {\n super({ code: 'NETWORK_ERROR', exitCode: 5, message, hint });\n }\n}\n\nexport class RelayConnectError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Verify the tunnel URL and that the auth-server is reachable.',\n ) {\n super({ code: 'RELAY_CONNECT', exitCode: 6, message, hint });\n }\n}\n\nexport class TemplateExistsError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Choose a different name or remove the existing directory.',\n ) {\n super({ code: 'TEMPLATE_EXISTS', exitCode: 7, message, hint });\n }\n}\n\nexport class ValidationError extends RatioCLIError {\n constructor(message: string, hint?: string) {\n const opts: { code: string; exitCode: number; message: string; hint?: string } = {\n code: 'VALIDATION_ERROR',\n exitCode: 8,\n message,\n };\n if (hint !== undefined) opts.hint = hint;\n super(opts);\n }\n}\n\nexport class RelayResumeGapError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Reconnect from the current cursor; older events are outside the buffer window.',\n ) {\n super({ code: 'RELAY_RESUME_GAP', exitCode: 9, message, hint });\n }\n}\n\nexport class OrchestratorSpawnError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Could not start your app's dev command — check that the command in ratio.config.jsonc runs from this directory.\",\n ) {\n super({ code: 'ORCHESTRATOR_SPAWN', exitCode: 10, message, hint });\n }\n}\n\nexport class OrchestratorDrainTimeoutError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Your dev command did not exit cleanly within 5 seconds — it was forcibly terminated.',\n ) {\n super({ code: 'ORCHESTRATOR_DRAIN', exitCode: 11, message, hint });\n }\n}\n\nexport function isRatioCLIError(err: unknown): err is RatioCLIError {\n return err instanceof RatioCLIError;\n}\n","/**\n * Injectable token-refresh seam for the credential store.\n *\n * The store never performs network I/O itself: when a stored token is\n * close to expiry the store calls the injected {@link TokenRefresher}\n * and persists whatever it returns. The concrete HTTP implementation is\n * wired in by the auth commands; this module only defines the seam plus\n * a throw-if-called placeholder used before sign-in support is wired.\n */\n\nimport { NotLoggedInError } from '../errors.js';\nimport type { TokenSet } from './types.js';\n\n/** Injectable seam that exchanges a refresh token for a fresh token set. */\nexport interface TokenRefresher {\n refresh(refreshToken: string): Promise<TokenSet>;\n}\n\n/**\n * v1 stub. Always rejects with a static-message NotLoggedInError; it\n * never inspects, echoes, or logs the token it was handed. The real\n * HTTP implementation is supplied by the auth commands.\n */\nexport class NotImplementedRefresher implements TokenRefresher {\n async refresh(refreshToken: string): Promise<TokenSet> {\n // Deliberately unused: this placeholder must never touch the token.\n void refreshToken;\n throw new NotLoggedInError(\n 'Token refresh is not available yet in this CLI version.',\n );\n }\n}\n","/**\n * File-backed credential store.\n *\n * Token resolution precedence (highest first):\n * 1. `RATIO_API_TOKEN` from the injected env reader — returned as-is\n * with ZERO filesystem access and ZERO refresher involvement.\n * 2. The requested profile (default: \"default\") from the credentials\n * file, silently refreshed via the injected TokenRefresher when it\n * is within 5 minutes of expiry.\n *\n * Write contract: atomic tmp + rename in the SAME directory, tmp created\n * with mode 0600 at open time (POSIX), parent directory created 0700,\n * belt-and-braces chmod 0600 after rename. On Windows no chmod / mode\n * checks are ever performed. Concurrent writers are last-writer-wins by\n * design — the atomic rename guarantees no half-written file, which is\n * sufficient for a single-developer CLI.\n *\n * No secret (access token, refresh token, raw file contents) is ever\n * included in an error message or a log line.\n */\n\nimport crypto from 'node:crypto';\nimport fs from 'node:fs';\nimport { dirname } from 'node:path';\nimport {\n NotLoggedInError,\n TokenExpiredError,\n ValidationError,\n} from '../errors.js';\nimport { SystemClock } from './clock.js';\nimport type { Clock } from './clock.js';\nimport { defaultCredentialsPath } from './path.js';\nimport { NotImplementedRefresher } from './refresher.js';\nimport type { TokenRefresher } from './refresher.js';\nimport { parseCredentials } from './schema.js';\nimport type { Credentials, DeveloperSession, Profile, TokenSet } from './types.js';\n\n/** Milliseconds before expiry at which a silent refresh is triggered. */\nconst REFRESH_WINDOW_MS = 300_000;\n\n/** Env var that short-circuits all file access when set to a non-empty string. */\nconst ENV_TOKEN_VAR = 'RATIO_API_TOKEN';\n\n/**\n * Env var that short-circuits developer-session file access when set to a\n * non-empty string. Strictly separate from RATIO_API_TOKEN: this one carries\n * the developer sign-in bearer used for app-management calls, never the\n * OAuth access token.\n */\nconst ENV_DEVELOPER_TOKEN_VAR = 'RATIO_DEVELOPER_TOKEN';\n\nexport interface CredentialStoreOptions {\n /** Defaults to defaultCredentialsPath(). */\n readonly filePath?: string;\n /** Defaults to new SystemClock(). */\n readonly clock?: Clock;\n /** Defaults to new NotImplementedRefresher(). */\n readonly refresher?: TokenRefresher;\n /** Defaults to () => process.env — injected for env-precedence tests. */\n readonly envReader?: () => Readonly<Record<string, string | undefined>>;\n /** Defaults to process.platform. */\n readonly platform?: NodeJS.Platform;\n}\n\nexport interface CredentialStore {\n /** Reads the full credentials file. Throws NotLoggedInError if missing. */\n load(): Promise<Credentials>;\n /** Atomic write with 0600 on POSIX. tmp + rename in same dir. */\n save(creds: Credentials): Promise<void>;\n /** Deletes the credentials file. Idempotent. */\n clear(): Promise<void>;\n /**\n * Env precedence: if envReader().RATIO_API_TOKEN is set, returns it\n * WITHOUT reading the file, WITHOUT touching the refresher, WITHOUT\n * any fs I/O. This is the auditable \"no disk access\" path.\n *\n * Otherwise: reads the file, validates, computes\n * msUntilExpiry = new Date(profile.expiresAt).getTime() - clock.now()\n * If msUntilExpiry < 300_000 (5 min), calls refresher.refresh(...),\n * persists the new TokenSet atomically, returns the new accessToken.\n * Otherwise returns profile.accessToken.\n */\n getValidAccessToken(profileName?: string): Promise<string>;\n /**\n * Env precedence: RATIO_DEVELOPER_TOKEN if set (no fs I/O).\n * Otherwise: load(); if creds.developer is absent or within 5 minutes of\n * expiry, throws NotLoggedInError ('Developer signin required.').\n * Otherwise returns creds.developer.jwt.\n * NO refresh attempt — the caller drives the re-prompt UX.\n */\n getValidDeveloperToken(): Promise<string>;\n /**\n * Merge a DeveloperSession into the loaded credentials (creating the file\n * if absent), atomic tmp+rename, 0600.\n */\n saveDeveloperSession(session: DeveloperSession): Promise<void>;\n}\n\n/** Narrow an unknown thrown value to a Node errno error with the given code. */\nfunction hasErrnoCode(err: unknown, code: string): boolean {\n return (\n err instanceof Error &&\n (err as NodeJS.ErrnoException).code === code\n );\n}\n\nexport class FileCredentialStore implements CredentialStore {\n private readonly filePath: string;\n private readonly clock: Clock;\n private readonly refresher: TokenRefresher;\n private readonly envReader: () => Readonly<\n Record<string, string | undefined>\n >;\n private readonly platform: NodeJS.Platform;\n\n constructor(opts?: CredentialStoreOptions) {\n this.filePath = opts?.filePath ?? defaultCredentialsPath();\n this.clock = opts?.clock ?? new SystemClock();\n this.refresher = opts?.refresher ?? new NotImplementedRefresher();\n this.envReader = opts?.envReader ?? (() => process.env);\n this.platform = opts?.platform ?? process.platform;\n }\n\n async load(): Promise<Credentials> {\n this.reapplyFileModeIfDrifted();\n\n let raw: string;\n try {\n raw = fs.readFileSync(this.filePath, 'utf8');\n } catch (err) {\n if (hasErrnoCode(err, 'ENOENT')) {\n throw new NotLoggedInError('You are not signed in.');\n }\n throw err;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Never echo the raw contents — they may hold a truncated token.\n throw new ValidationError(\n 'credentials file is not valid JSON — run \"ratio auth login\" to reset',\n );\n }\n\n return parseCredentials(parsed);\n }\n\n async save(creds: Credentials): Promise<void> {\n const dir = dirname(this.filePath);\n fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n if (this.platform !== 'win32') {\n // mkdirSync does not re-mode a pre-existing directory; re-apply.\n fs.chmodSync(dir, 0o700);\n }\n\n const tmpPath = `${this.filePath}.${process.pid}.${crypto\n .randomBytes(8)\n .toString('hex')}.tmp`;\n try {\n // mode at open time closes the create-then-chmod race window.\n fs.writeFileSync(tmpPath, JSON.stringify(creds, null, 2), {\n mode: 0o600,\n });\n fs.renameSync(tmpPath, this.filePath);\n } catch (err) {\n try {\n fs.unlinkSync(tmpPath);\n } catch {\n // Best-effort cleanup: the tmp file may never have been created.\n }\n throw err;\n }\n\n if (this.platform !== 'win32') {\n // Belt-and-braces: rename may preserve a prior destination mode.\n fs.chmodSync(this.filePath, 0o600);\n }\n }\n\n async clear(): Promise<void> {\n try {\n fs.unlinkSync(this.filePath);\n } catch (err) {\n if (hasErrnoCode(err, 'ENOENT')) {\n return; // Idempotent: already gone.\n }\n throw err;\n }\n }\n\n async getValidAccessToken(profileName?: string): Promise<string> {\n const envToken = this.envReader()[ENV_TOKEN_VAR];\n if (typeof envToken === 'string' && envToken.length > 0) {\n return envToken;\n }\n\n const creds = await this.load();\n const name = profileName ?? 'default';\n const profile = creds.profiles[name];\n if (profile === undefined) {\n throw new NotLoggedInError('You are not signed in.');\n }\n\n const msUntilExpiry =\n new Date(profile.expiresAt).getTime() - this.clock.now();\n if (msUntilExpiry < REFRESH_WINDOW_MS) {\n return this.refreshAndPersist(creds, name, profile);\n }\n\n return profile.accessToken;\n }\n\n async getValidDeveloperToken(): Promise<string> {\n const envToken = this.envReader()[ENV_DEVELOPER_TOKEN_VAR];\n if (typeof envToken === 'string' && envToken.length > 0) {\n return envToken;\n }\n\n let creds: Credentials;\n try {\n creds = await this.load();\n } catch (err) {\n if (err instanceof NotLoggedInError) {\n throw new NotLoggedInError('Developer signin required.');\n }\n throw err;\n }\n\n const session = creds.developer;\n if (session === undefined) {\n throw new NotLoggedInError('Developer signin required.');\n }\n\n const msUntilExpiry =\n new Date(session.expiresAt).getTime() - this.clock.now();\n if (msUntilExpiry < REFRESH_WINDOW_MS) {\n // No refresh endpoint exists for developer sessions — the caller\n // drives a fresh interactive sign-in instead.\n throw new NotLoggedInError('Developer signin required.');\n }\n\n return session.jwt;\n }\n\n async saveDeveloperSession(session: DeveloperSession): Promise<void> {\n let creds: Credentials;\n try {\n creds = await this.load();\n } catch (err) {\n if (err instanceof NotLoggedInError) {\n // Edge case: no prior credentials file. Create a fresh v1 shell so\n // the merged write below still validates on the next load().\n creds = { version: 1, server: '', clientId: '', profiles: {} };\n } else {\n throw err;\n }\n }\n\n const updated: Credentials = {\n ...creds,\n developer: session,\n };\n await this.save(updated);\n }\n\n /**\n * Exchange the profile's refresh token for a fresh token set, merge it\n * into the loaded credentials (preserving every other profile), persist\n * atomically, and return the new access token. A refresher failure is\n * wrapped in TokenExpiredError; the underlying message is deliberately\n * discarded so a secret can never leak through the error surface.\n */\n private async refreshAndPersist(\n creds: Credentials,\n name: string,\n profile: Profile,\n ): Promise<string> {\n let refreshed: TokenSet;\n try {\n refreshed = await this.refresher.refresh(profile.refreshToken);\n } catch {\n throw new TokenExpiredError(\n 'Your session has expired and could not be refreshed.',\n );\n }\n\n const updated: Credentials = {\n ...creds,\n profiles: {\n ...creds.profiles,\n [name]: {\n ...profile,\n accessToken: refreshed.accessToken,\n refreshToken: refreshed.refreshToken,\n expiresAt: refreshed.expiresAt,\n },\n },\n };\n await this.save(updated);\n return refreshed.accessToken;\n }\n\n /**\n * POSIX-only startup permission check: if the file exists with a mode\n * other than 0600, re-apply 0600 and emit a warning to stderr. Never\n * runs on Windows (stat modes there do not map to POSIX bits and chmod\n * must not be called). Missing file is NOT an error here — load()\n * reports it as NotLoggedInError.\n */\n private reapplyFileModeIfDrifted(): void {\n if (this.platform === 'win32') {\n return;\n }\n\n let stat: fs.Stats;\n try {\n stat = fs.statSync(this.filePath);\n } catch (err) {\n if (hasErrnoCode(err, 'ENOENT')) {\n return; // load() turns the missing file into NotLoggedInError.\n }\n throw err;\n }\n\n const mode = stat.mode & 0o777;\n if (mode !== 0o600) {\n fs.chmodSync(this.filePath, 0o600);\n process.stderr.write(\n `[warn] credentials file permissions were 0o${mode.toString(8)}; corrected to 0600.\\n`,\n );\n }\n }\n}\n","/**\n * Runtime validation for the on-disk credentials file.\n *\n * The parser is deliberately strict: unknown top-level keys, missing\n * fields, non-ISO timestamps, and any `version` other than `1` are all\n * rejected with a typed ValidationError. Error messages NEVER embed the\n * raw file contents — a malformed file may contain a truncated token.\n */\n\nimport { z } from 'zod';\nimport { ValidationError } from '../errors.js';\nimport type { Credentials } from './types.js';\n\nconst identitySchema = z\n .object({\n developerId: z.string(),\n email: z.string(),\n // Token-derived fields captured at login. Optional (additive) so files\n // written before these fields existed still validate as version 1.\n merchantId: z.string().optional(),\n scopes: z.array(z.string()).optional(),\n })\n .strict();\n\nconst profileSchema = z\n .object({\n accessToken: z.string(),\n refreshToken: z.string(),\n expiresAt: z.string().datetime({ offset: true }),\n identity: identitySchema,\n obtainedAt: z.string().datetime({ offset: true }),\n })\n .strict();\n\nconst developerSessionSchema = z\n .object({\n jwt: z.string(),\n developerId: z.string(),\n email: z.string(),\n expiresAt: z.string().datetime({ offset: true }),\n obtainedAt: z.string().datetime({ offset: true }),\n })\n .strict();\n\n/** Strict zod schema for the version-1 credentials file. */\nexport const credentialsSchema = z\n .object({\n version: z.literal(1),\n server: z.string(),\n clientId: z.string(),\n profiles: z.record(z.string(), profileSchema),\n // Optional cached developer session. Additive so files written before\n // this field existed still validate as version 1.\n developer: developerSessionSchema.optional(),\n })\n .strict();\n\n/**\n * Validate an already-JSON-parsed value against the version-1 shape.\n *\n * Throws ValidationError on any mismatch. A recognisable-but-unsupported\n * `version` gets a dedicated upgrade message; the version label is only\n * echoed when it is a number so arbitrary file contents can never leak\n * into an error message.\n */\nexport function parseCredentials(input: unknown): Credentials {\n if (typeof input === 'object' && input !== null && 'version' in input) {\n const version = (input as { version: unknown }).version;\n if (version !== 1) {\n const label = typeof version === 'number' ? String(version) : 'unknown';\n throw new ValidationError(\n `credentials file version ${label} is unsupported by this CLI — upgrade the CLI or sign in again`,\n );\n }\n }\n\n const result = credentialsSchema.safeParse(input);\n if (!result.success) {\n throw new ValidationError(\n 'credentials file schema mismatch — expected v1 shape',\n );\n }\n return result.data as Credentials;\n}\n","/**\n * Template scaffold engine for `ratio app create`.\n *\n * Renders a bundled app template into a destination directory:\n *\n * - Files ending in `.tmpl` are read as UTF-8 and every `{{key}}`\n * placeholder is substituted from the provided substitution map,\n * then written with the `.tmpl` suffix stripped\n * (`package.json.tmpl` → `package.json`).\n * - All other files are copied byte-for-byte.\n *\n * The engine is intentionally minimal: a single-pass\n * `String.replace(/\\{\\{key\\}\\}/g, …)` with a replacer FUNCTION (so\n * `$&`, `$1`, `$$` in substitution values are never interpreted as\n * special replacement patterns). There is no template library and no\n * recursive substitution — if a substitution value itself contains\n * `{{...}}`, that literal text appears in the output untouched.\n *\n * Every template root carries a `manifest.json`:\n *\n * ```json\n * {\n * \"name\": \"minimal\",\n * \"description\": \"Express-based Ratio app (default).\",\n * \"substitutionKeys\": [\"appName\", \"clientId\", \"sdkVersion\", \"templateName\"]\n * }\n * ```\n *\n * `substitutionKeys` is the closed set of placeholders the template's\n * `.tmpl` files may use. A `{{key}}` found in a `.tmpl` file that is\n * not declared in the manifest is a render-time error\n * (`unknown_substitution_key`). The `manifest.json` itself is copied\n * verbatim into the generated app so tooling can re-inspect which\n * template produced it.\n *\n * The canonical `ratio.config.jsonc` produced by every template has\n * this shape (the scaffolder writes it; `ratio app link` later fills\n * `clientId`):\n *\n * ```jsonc\n * {\n * // Canonical Ratio app manifest. Committed to the app repo.\n * // Written by scaffolder; `clientId` filled by `ratio app link`.\n * \"$schema\": \"https://ratio.app/schema/app-manifest.v1.json\",\n * \"appName\": \"{{appName}}\",\n * \"clientId\": \"{{clientId}}\",\n * \"sdkVersion\": \"{{sdkVersion}}\",\n * \"template\": \"{{templateName}}\",\n * \"webhooks\": {\n * \"path\": \"/webhooks\",\n * \"topics\": []\n * },\n * \"oauth\": {\n * \"redirectPath\": \"/oauth/callback\",\n * \"scopes\": []\n * }\n * }\n * ```\n *\n * The engine does not hardcode that shape — it only substitutes keys —\n * but the block above documents the contract for template authors.\n */\n\nimport { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';\nimport { dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/** Per-template metadata, loaded from `<templateRoot>/manifest.json`. */\nexport interface TemplateManifest {\n /** Template identifier, e.g. 'minimal' | 'with-admin-ui' | 'serverless'. */\n readonly name: string;\n readonly description: string;\n /** Closed set of `{{key}}` placeholders the template's `.tmpl` files may use. */\n readonly substitutionKeys: readonly string[];\n}\n\nexport interface RenderInput {\n /** Must match one of the discovered templates. */\n readonly templateName: string;\n /** Absolute path to write into. Must be empty (or not yet exist). */\n readonly destinationDir: string;\n readonly substitutions: Readonly<Record<string, string>>;\n /** Overrides template-root discovery (test injection). */\n readonly sourceRootOverride?: string;\n /** Called once per file AFTER a successful write. Bytes = per-file, NOT cumulative. */\n readonly onFile?: (relativePath: string, bytesWritten: number) => void;\n}\n\nexport interface RenderResult {\n /** Absolute paths, in write order. */\n readonly filesWritten: readonly string[];\n readonly bytesWritten: number;\n}\n\nexport type RatioTemplateErrorCode =\n | 'template_not_found'\n | 'unknown_substitution_key'\n | 'missing_substitution_value'\n | 'destination_not_empty'\n | 'io_error';\n\n/** Error surface for the scaffold engine. All failures carry a stable `code`. */\nexport class RatioTemplateError extends Error {\n readonly code: RatioTemplateErrorCode;\n readonly templateName?: string;\n readonly key?: string;\n\n constructor(opts: {\n readonly code: RatioTemplateErrorCode;\n readonly message: string;\n readonly templateName?: string;\n readonly key?: string;\n readonly cause?: unknown;\n }) {\n super(opts.message, opts.cause === undefined ? undefined : { cause: opts.cause });\n this.name = 'RatioTemplateError';\n this.code = opts.code;\n if (opts.templateName !== undefined) {\n this.templateName = opts.templateName;\n }\n if (opts.key !== undefined) {\n this.key = opts.key;\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Private: placeholder syntax\n// ---------------------------------------------------------------------------\n\n/** `{{key}}` where key is a leading-alpha identifier. Single pass, global. */\nconst PLACEHOLDER_REGEX = /\\{\\{([a-zA-Z][a-zA-Z0-9_]*)\\}\\}/g;\n\nconst TMPL_SUFFIX = '.tmpl';\n\n// ---------------------------------------------------------------------------\n// Private: source-root discovery\n// ---------------------------------------------------------------------------\n\nasync function isDirectory(path: string): Promise<boolean> {\n try {\n return (await stat(path)).isDirectory();\n } catch {\n return false;\n }\n}\n\n/**\n * Locate the templates root. Order:\n *\n * 1. `sourceRootOverride` when provided (test injection).\n * 2. `templates/` next to the compiled module (production —\n * `dist/templates/`, copied there at build time).\n * 3. `templates/` one level up from the module dir (dev — this file\n * lives at `src/scaffold/`, templates at `src/templates/`).\n */\nasync function discoverSourceRoot(sourceRootOverride?: string): Promise<string> {\n if (sourceRootOverride !== undefined) {\n return sourceRootOverride;\n }\n const moduleDir = dirname(fileURLToPath(import.meta.url));\n const candidates = [join(moduleDir, 'templates'), resolve(moduleDir, '..', 'templates')];\n for (const candidate of candidates) {\n if (await isDirectory(candidate)) {\n return candidate;\n }\n }\n throw new RatioTemplateError({\n code: 'io_error',\n message: `templates root not found (looked in: ${candidates.join(', ')})`,\n });\n}\n\n// ---------------------------------------------------------------------------\n// Private: manifest loading\n// ---------------------------------------------------------------------------\n\nfunction assertManifestShape(raw: unknown, path: string): TemplateManifest {\n if (\n typeof raw !== 'object' ||\n raw === null ||\n typeof (raw as { name?: unknown }).name !== 'string' ||\n typeof (raw as { description?: unknown }).description !== 'string' ||\n !Array.isArray((raw as { substitutionKeys?: unknown }).substitutionKeys) ||\n !(raw as { substitutionKeys: unknown[] }).substitutionKeys.every(\n (k): k is string => typeof k === 'string',\n )\n ) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `invalid template manifest at ${path}: expected { name, description, substitutionKeys[] }`,\n });\n }\n const shaped = raw as { name: string; description: string; substitutionKeys: string[] };\n return {\n name: shaped.name,\n description: shaped.description,\n substitutionKeys: shaped.substitutionKeys,\n };\n}\n\nasync function loadManifest(templateRoot: string): Promise<TemplateManifest> {\n const manifestPath = join(templateRoot, 'manifest.json');\n let content: string;\n try {\n content = await readFile(manifestPath, 'utf8');\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `failed to read template manifest at ${manifestPath}`,\n cause: err,\n });\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(content);\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `template manifest at ${manifestPath} is not valid JSON`,\n cause: err,\n });\n }\n return assertManifestShape(parsed, manifestPath);\n}\n\n// ---------------------------------------------------------------------------\n// Private: substitution (single pass, replacer function — never a string)\n// ---------------------------------------------------------------------------\n\nfunction substituteContent(\n content: string,\n substitutions: Readonly<Record<string, string>>,\n allowedKeys: ReadonlySet<string>,\n templateName: string,\n): string {\n // Replacer FUNCTION: return values are inserted literally, so `$&`,\n // `$1`, `$$` in a substitution value are never expanded. Output is\n // NOT re-scanned — no recursive substitution.\n return content.replace(PLACEHOLDER_REGEX, (_match, key: string) => {\n if (!allowedKeys.has(key)) {\n throw new RatioTemplateError({\n code: 'unknown_substitution_key',\n message: `placeholder {{${key}}} is not declared in the template manifest's substitutionKeys`,\n templateName,\n key,\n });\n }\n const value = substitutions[key];\n if (value === undefined) {\n throw new RatioTemplateError({\n code: 'missing_substitution_value',\n message: `no substitution value provided for {{${key}}}`,\n templateName,\n key,\n });\n }\n return value;\n });\n}\n\n// ---------------------------------------------------------------------------\n// Private: tree walk (stable, sorted order)\n// ---------------------------------------------------------------------------\n\ninterface TemplateFile {\n /** Path relative to the template root, POSIX-joined by node:path. */\n readonly relativePath: string;\n readonly absolutePath: string;\n}\n\nasync function walkTemplateTree(root: string, relative = ''): Promise<TemplateFile[]> {\n const entries = await readdir(join(root, relative), { withFileTypes: true });\n entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));\n const files: TemplateFile[] = [];\n for (const entry of entries) {\n const rel = relative === '' ? entry.name : join(relative, entry.name);\n if (entry.isDirectory()) {\n files.push(...(await walkTemplateTree(root, rel)));\n } else {\n files.push({ relativePath: rel, absolutePath: join(root, rel) });\n }\n }\n return files;\n}\n\n// ---------------------------------------------------------------------------\n// Public: listTemplates\n// ---------------------------------------------------------------------------\n\n/**\n * Discover all bundled templates and return their manifests, sorted by\n * template directory name for a stable order.\n */\nexport async function listTemplates(\n sourceRootOverride?: string,\n): Promise<readonly TemplateManifest[]> {\n const sourceRoot = await discoverSourceRoot(sourceRootOverride);\n let entries;\n try {\n entries = await readdir(sourceRoot, { withFileTypes: true });\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `failed to read templates root at ${sourceRoot}`,\n cause: err,\n });\n }\n const dirs = entries\n .filter((e) => e.isDirectory())\n .map((e) => e.name)\n .sort();\n const manifests: TemplateManifest[] = [];\n for (const dir of dirs) {\n manifests.push(await loadManifest(join(sourceRoot, dir)));\n }\n return manifests;\n}\n\n// ---------------------------------------------------------------------------\n// Public: renderTemplate\n// ---------------------------------------------------------------------------\n\n/**\n * Render the named template into `destinationDir`.\n *\n * Strict about its destination: if the directory already contains any\n * entry the render throws `destination_not_empty` — the caller owns\n * the \"directory already has content\" UX.\n */\nexport async function renderTemplate(input: RenderInput): Promise<RenderResult> {\n const sourceRoot = await discoverSourceRoot(input.sourceRootOverride);\n const templateRoot = join(sourceRoot, input.templateName);\n\n if (!(await isDirectory(templateRoot))) {\n throw new RatioTemplateError({\n code: 'template_not_found',\n message: `template '${input.templateName}' not found under ${sourceRoot}`,\n templateName: input.templateName,\n });\n }\n\n const manifest = await loadManifest(templateRoot);\n const allowedKeys = new Set(manifest.substitutionKeys);\n\n // Destination must be empty (or absent — created on demand).\n try {\n const existing = await readdir(input.destinationDir);\n if (existing.length > 0) {\n throw new RatioTemplateError({\n code: 'destination_not_empty',\n message: `destination directory ${input.destinationDir} is not empty`,\n templateName: input.templateName,\n });\n }\n } catch (err) {\n if (err instanceof RatioTemplateError) {\n throw err;\n }\n if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `failed to inspect destination directory ${input.destinationDir}`,\n templateName: input.templateName,\n cause: err,\n });\n }\n }\n\n let files: TemplateFile[];\n try {\n files = await walkTemplateTree(templateRoot);\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `failed to walk template tree at ${templateRoot}`,\n templateName: input.templateName,\n cause: err,\n });\n }\n\n const filesWritten: string[] = [];\n let bytesWritten = 0;\n\n for (const file of files) {\n const isTmpl = file.relativePath.endsWith(TMPL_SUFFIX);\n const outRelative = isTmpl\n ? file.relativePath.slice(0, -TMPL_SUFFIX.length)\n : file.relativePath;\n // npm-packlist strips `.gitignore` files from the published tarball, so\n // templates ship the file as `gitignore` (template ROOT only — no `/` in\n // the relative path) and the engine renames it back on render. This is a\n // single explicit special case, not a general dot-strip convention.\n const isTopLevelGitignore = file.relativePath === 'gitignore';\n const effectiveOutRelative = isTopLevelGitignore ? '.gitignore' : outRelative;\n const outPath = join(input.destinationDir, effectiveOutRelative);\n let perFileBytes = 0;\n\n if (isTmpl) {\n let raw: string;\n try {\n raw = await readFile(file.absolutePath, 'utf8');\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `failed to read template file ${file.absolutePath}`,\n templateName: input.templateName,\n cause: err,\n });\n }\n // May throw unknown_substitution_key / missing_substitution_value —\n // deliberately NOT wrapped as io_error.\n const rendered = substituteContent(\n raw,\n input.substitutions,\n allowedKeys,\n input.templateName,\n );\n try {\n await mkdir(dirname(outPath), { recursive: true });\n await writeFile(outPath, rendered, 'utf8');\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `failed to write rendered file ${outPath}`,\n templateName: input.templateName,\n cause: err,\n });\n }\n perFileBytes = Buffer.byteLength(rendered, 'utf8');\n bytesWritten += perFileBytes;\n } else {\n try {\n await mkdir(dirname(outPath), { recursive: true });\n await copyFile(file.absolutePath, outPath);\n perFileBytes = (await stat(outPath)).size;\n bytesWritten += perFileBytes;\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: `failed to copy file ${file.absolutePath} to ${outPath}`,\n templateName: input.templateName,\n cause: err,\n });\n }\n }\n filesWritten.push(outPath);\n if (input.onFile !== undefined) {\n try {\n input.onFile(effectiveOutRelative, perFileBytes);\n } catch (err) {\n throw new RatioTemplateError({\n code: 'io_error',\n message: 'onFile callback threw',\n templateName: input.templateName,\n cause: err,\n });\n }\n }\n }\n\n return { filesWritten, bytesWritten };\n}\n","/**\n * Official Ratio developer CLI.\n */\nexport const CLI_NAME = '@ratio-app/cli';\n\n// Dev-store gate — re-exported so external consumers can depend on the\n// interface and the singleton factory without importing the internal path.\nexport type { DevStoreGate, DevStoreGateContext } from './lib/dev-store-gate.js';\nexport { NoopDevStoreGate, getDevStoreGate } from './lib/dev-store-gate.js';\n\n// Credential store — re-exported so external consumers can depend on\n// the interface and the default implementation without importing the\n// internal path.\nexport type {\n Credentials,\n Profile,\n Identity,\n TokenSet,\n Clock,\n PathEnv,\n TokenRefresher,\n CredentialStore,\n CredentialStoreOptions,\n} from './lib/credentials/index.js';\nexport {\n SystemClock,\n defaultCredentialsPath,\n NotImplementedRefresher,\n FileCredentialStore,\n} from './lib/credentials/index.js';\n\n// Scaffold engine — re-exported so the app-create scaffolder (and\n// external consumers) can render bundled templates without importing\n// the internal path.\nexport type { TemplateManifest, RenderInput, RenderResult } from './scaffold/index.js';\nexport { renderTemplate, listTemplates, RatioTemplateError } from './scaffold/index.js';\n"],"mappings":"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","names":["base","stat","dirname","path"]}
@@ -0,0 +1,50 @@
1
+ import { TokenSet } from '../credentials/types.js';
2
+ import { RATIO_CLI_CLIENT_ID } from './config.js';
3
+ import { VerboseTracer } from './verbose-trace.js';
4
+
5
+ /**
6
+ * OAuth authorization-code exchange step.
7
+ *
8
+ * Used by BOTH the browser flow (with redirectUri) and the manual flow
9
+ * (without redirectUri). Returns typed tokens + identity derived from
10
+ * the token response.
11
+ *
12
+ * A-29 adjudication: identity = token-derived {clientId, merchantId, scopes}.
13
+ * The backend token envelope does not expose developerId/email. This is a
14
+ * known ADR contradiction resolved pre-implementation; the schema tolerates
15
+ * absence and additive identity fields can be added when a backend endpoint
16
+ * for developer identity is available.
17
+ *
18
+ * Token response schema is pinned with zod for fast detection of envelope drift.
19
+ * Any schema mismatch throws a static-message ValidationError — the raw body
20
+ * is never included in any error message (secret-leak prevention).
21
+ */
22
+
23
+ /** Token-derived identity (A-29 shape). */
24
+ interface TokenDerivedIdentity {
25
+ readonly clientId: string;
26
+ readonly merchantId: string;
27
+ readonly scopes: readonly string[];
28
+ }
29
+ interface CodeExchangeInput {
30
+ readonly serverUrl: string;
31
+ readonly code: string;
32
+ readonly codeVerifier: string;
33
+ readonly redirectUri?: string;
34
+ readonly clientId: typeof RATIO_CLI_CLIENT_ID;
35
+ readonly fetchImpl?: typeof fetch;
36
+ /** Verbose diagnostic sink — never receives token/code/verifier/state. */
37
+ readonly trace?: VerboseTracer;
38
+ }
39
+ interface CodeExchangeOutput {
40
+ readonly tokens: TokenSet;
41
+ readonly identity: TokenDerivedIdentity;
42
+ }
43
+ /**
44
+ * Exchange an authorization code for tokens.
45
+ * POSTs to /oauth/token with grant_type=authorization_code.
46
+ * Never logs or includes token values in error messages.
47
+ */
48
+ declare function exchangeCodeForTokens(i: CodeExchangeInput): Promise<CodeExchangeOutput>;
49
+
50
+ export { type CodeExchangeInput, type CodeExchangeOutput, type TokenDerivedIdentity, exchangeCodeForTokens };
@@ -0,0 +1,114 @@
1
+ // src/lib/auth/authorize-code-exchange.ts
2
+ import { z } from "zod";
3
+
4
+ // src/lib/errors.ts
5
+ var RatioCLIError = class extends Error {
6
+ code;
7
+ exitCode;
8
+ hint;
9
+ docsUrl;
10
+ constructor(opts) {
11
+ super(opts.message);
12
+ this.name = new.target.name;
13
+ this.code = opts.code;
14
+ this.exitCode = opts.exitCode;
15
+ if (opts.hint !== void 0) this.hint = opts.hint;
16
+ if (opts.docsUrl !== void 0) this.docsUrl = opts.docsUrl;
17
+ }
18
+ };
19
+ var NetworkError = class extends RatioCLIError {
20
+ constructor(message, hint = "Check your network and retry.") {
21
+ super({ code: "NETWORK_ERROR", exitCode: 5, message, hint });
22
+ }
23
+ };
24
+ var ValidationError = class extends RatioCLIError {
25
+ constructor(message, hint) {
26
+ const opts = {
27
+ code: "VALIDATION_ERROR",
28
+ exitCode: 8,
29
+ message
30
+ };
31
+ if (hint !== void 0) opts.hint = hint;
32
+ super(opts);
33
+ }
34
+ };
35
+
36
+ // src/lib/auth/verbose-trace.ts
37
+ var noopTracer = () => {
38
+ };
39
+
40
+ // src/lib/auth/authorize-code-exchange.ts
41
+ var TokenResponseSchema = z.object({
42
+ access_token: z.string().min(1),
43
+ refresh_token: z.string().min(1),
44
+ expires_in: z.number().int().positive(),
45
+ token_type: z.string().optional(),
46
+ scope: z.string().optional(),
47
+ merchant_id: z.string().optional()
48
+ });
49
+ function exchangeCodeForTokens(i) {
50
+ return _exchangeCodeForTokensImpl(i);
51
+ }
52
+ async function _exchangeCodeForTokensImpl(i) {
53
+ const fetchFn = i.fetchImpl ?? fetch;
54
+ const body = {
55
+ grant_type: "authorization_code",
56
+ code: i.code,
57
+ clientId: i.clientId,
58
+ codeVerifier: i.codeVerifier
59
+ };
60
+ if (i.redirectUri !== void 0) {
61
+ body["redirectUri"] = i.redirectUri;
62
+ }
63
+ const trace = i.trace ?? noopTracer;
64
+ const startedAt = Date.now();
65
+ let response;
66
+ try {
67
+ response = await fetchFn(`${i.serverUrl}/oauth/token`, {
68
+ method: "POST",
69
+ headers: { "Content-Type": "application/json" },
70
+ body: JSON.stringify(body)
71
+ });
72
+ } catch {
73
+ trace(`POST /oauth/token \u2192 network error (${Date.now() - startedAt}ms)`);
74
+ throw new NetworkError("Failed to reach the Ratio server during token exchange.");
75
+ }
76
+ trace(`POST /oauth/token \u2192 ${response.status} (${Date.now() - startedAt}ms)`);
77
+ if (response.status >= 400 && response.status < 500) {
78
+ throw new ValidationError(
79
+ "The authorization code was rejected. Run 'ratio auth login' again."
80
+ );
81
+ }
82
+ if (response.status >= 500 || !response.ok) {
83
+ throw new NetworkError("The Ratio server returned an error during token exchange.");
84
+ }
85
+ let raw;
86
+ try {
87
+ raw = await response.json();
88
+ } catch {
89
+ throw new ValidationError("Token response was not valid JSON.");
90
+ }
91
+ const parsed = TokenResponseSchema.safeParse(raw);
92
+ if (!parsed.success) {
93
+ throw new ValidationError("Token response shape was unexpected. Run ratio auth login again.");
94
+ }
95
+ const data = parsed.data;
96
+ const expiresAt = new Date(Date.now() + data.expires_in * 1e3).toISOString();
97
+ const tokens = {
98
+ accessToken: data.access_token,
99
+ refreshToken: data.refresh_token,
100
+ expiresAt
101
+ };
102
+ const scopeStr = data.scope ?? "";
103
+ const scopes = scopeStr.length > 0 ? scopeStr.split(/[\s,]+/).filter(Boolean) : [];
104
+ const identity = {
105
+ clientId: i.clientId,
106
+ merchantId: data.merchant_id ?? "ratio-cli-dev",
107
+ scopes
108
+ };
109
+ return { tokens, identity };
110
+ }
111
+ export {
112
+ exchangeCodeForTokens
113
+ };
114
+ //# sourceMappingURL=authorize-code-exchange.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/lib/auth/authorize-code-exchange.ts","../../../src/lib/errors.ts","../../../src/lib/auth/verbose-trace.ts"],"sourcesContent":["/**\n * OAuth authorization-code exchange step.\n *\n * Used by BOTH the browser flow (with redirectUri) and the manual flow\n * (without redirectUri). Returns typed tokens + identity derived from\n * the token response.\n *\n * A-29 adjudication: identity = token-derived {clientId, merchantId, scopes}.\n * The backend token envelope does not expose developerId/email. This is a\n * known ADR contradiction resolved pre-implementation; the schema tolerates\n * absence and additive identity fields can be added when a backend endpoint\n * for developer identity is available.\n *\n * Token response schema is pinned with zod for fast detection of envelope drift.\n * Any schema mismatch throws a static-message ValidationError — the raw body\n * is never included in any error message (secret-leak prevention).\n */\n\nimport { z } from 'zod';\nimport { NetworkError, ValidationError } from '../errors.js';\nimport type { TokenSet } from '../credentials/index.js';\nimport { RATIO_CLI_CLIENT_ID } from './config.js';\nimport { noopTracer } from './verbose-trace.js';\nimport type { VerboseTracer } from './verbose-trace.js';\n\n/** Token response envelope from POST /oauth/token (authorization_code grant). */\nconst TokenResponseSchema = z.object({\n access_token: z.string().min(1),\n refresh_token: z.string().min(1),\n expires_in: z.number().int().positive(),\n token_type: z.string().optional(),\n scope: z.string().optional(),\n merchant_id: z.string().optional(),\n});\n\n/** Token-derived identity (A-29 shape). */\nexport interface TokenDerivedIdentity {\n readonly clientId: string;\n readonly merchantId: string;\n readonly scopes: readonly string[];\n}\n\nexport interface CodeExchangeInput {\n readonly serverUrl: string;\n readonly code: string;\n readonly codeVerifier: string;\n readonly redirectUri?: string; // undefined for --manual\n readonly clientId: typeof RATIO_CLI_CLIENT_ID;\n readonly fetchImpl?: typeof fetch; // injectable for tests\n /** Verbose diagnostic sink — never receives token/code/verifier/state. */\n readonly trace?: VerboseTracer;\n}\n\nexport interface CodeExchangeOutput {\n readonly tokens: TokenSet;\n readonly identity: TokenDerivedIdentity;\n}\n\n/**\n * Exchange an authorization code for tokens.\n * POSTs to /oauth/token with grant_type=authorization_code.\n * Never logs or includes token values in error messages.\n */\nexport function exchangeCodeForTokens(i: CodeExchangeInput): Promise<CodeExchangeOutput> {\n return _exchangeCodeForTokensImpl(i);\n}\n\nasync function _exchangeCodeForTokensImpl(i: CodeExchangeInput): Promise<CodeExchangeOutput> {\n const fetchFn = i.fetchImpl ?? fetch;\n\n const body: Record<string, string> = {\n grant_type: 'authorization_code',\n code: i.code,\n clientId: i.clientId,\n codeVerifier: i.codeVerifier,\n };\n if (i.redirectUri !== undefined) {\n body['redirectUri'] = i.redirectUri;\n }\n\n const trace = i.trace ?? noopTracer;\n const startedAt = Date.now();\n\n let response: Response;\n try {\n response = await fetchFn(`${i.serverUrl}/oauth/token`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n } catch {\n trace(`POST /oauth/token → network error (${Date.now() - startedAt}ms)`);\n throw new NetworkError('Failed to reach the Ratio server during token exchange.');\n }\n\n trace(`POST /oauth/token → ${response.status} (${Date.now() - startedAt}ms)`);\n\n if (response.status >= 400 && response.status < 500) {\n throw new ValidationError(\n \"The authorization code was rejected. Run 'ratio auth login' again.\",\n );\n }\n\n if (response.status >= 500 || !response.ok) {\n throw new NetworkError('The Ratio server returned an error during token exchange.');\n }\n\n let raw: unknown;\n try {\n raw = await response.json();\n } catch {\n throw new ValidationError('Token response was not valid JSON.');\n }\n\n const parsed = TokenResponseSchema.safeParse(raw);\n if (!parsed.success) {\n throw new ValidationError('Token response shape was unexpected. Run ratio auth login again.');\n }\n\n const data = parsed.data;\n const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();\n\n const tokens: TokenSet = {\n accessToken: data.access_token,\n refreshToken: data.refresh_token,\n expiresAt,\n };\n\n const scopeStr = data.scope ?? '';\n const scopes = scopeStr.length > 0 ? scopeStr.split(/[\\s,]+/).filter(Boolean) : [];\n\n const identity: TokenDerivedIdentity = {\n clientId: i.clientId,\n merchantId: data.merchant_id ?? 'ratio-cli-dev',\n scopes,\n };\n\n return { tokens, identity };\n}\n","/**\n * Error hierarchy for the Ratio CLI. Every error thrown from a command's\n * run() is expected to be an instance of RatioCLIError so the global\n * render-error handler can map it to a deterministic stderr line and exit\n * code. Uncaught Error subclasses render as \"INTERNAL: <msg>\" with exit 1.\n *\n * No import from @oclif/core — this file is dependency-free so tests\n * can import it without oclif overhead.\n */\n\nexport class RatioCLIError extends Error {\n readonly code: string;\n readonly exitCode: number;\n readonly hint?: string;\n readonly docsUrl?: string;\n\n constructor(opts: {\n code: string;\n exitCode: number;\n message: string;\n hint?: string;\n docsUrl?: string;\n }) {\n super(opts.message);\n this.name = new.target.name;\n this.code = opts.code;\n this.exitCode = opts.exitCode;\n if (opts.hint !== undefined) this.hint = opts.hint;\n if (opts.docsUrl !== undefined) this.docsUrl = opts.docsUrl;\n }\n}\n\n/** Coming-soon stub UX. Renders on stdout with exit 0. */\nexport class ComingSoonError extends RatioCLIError {\n constructor(message: string) {\n super({ code: 'COMING_SOON', exitCode: 0, message });\n }\n}\n\n/** User has no credentials and command requires auth. */\nexport class NotLoggedInError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Run 'ratio auth login' to sign in.\",\n ) {\n super({ code: 'NOT_LOGGED_IN', exitCode: 3, message, hint });\n }\n}\n\nexport class TokenExpiredError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Run 'ratio auth login' to refresh your session.\",\n ) {\n super({ code: 'TOKEN_EXPIRED', exitCode: 3, message, hint });\n }\n}\n\nexport class ScopeDeniedError extends RatioCLIError {\n constructor(message: string, hint?: string) {\n const opts: { code: string; exitCode: number; message: string; hint?: string } = {\n code: 'SCOPE_DENIED',\n exitCode: 4,\n message,\n };\n if (hint !== undefined) opts.hint = hint;\n super(opts);\n }\n}\n\nexport class NetworkError extends RatioCLIError {\n constructor(message: string, hint: string = 'Check your network and retry.') {\n super({ code: 'NETWORK_ERROR', exitCode: 5, message, hint });\n }\n}\n\nexport class RelayConnectError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Verify the tunnel URL and that the auth-server is reachable.',\n ) {\n super({ code: 'RELAY_CONNECT', exitCode: 6, message, hint });\n }\n}\n\nexport class TemplateExistsError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Choose a different name or remove the existing directory.',\n ) {\n super({ code: 'TEMPLATE_EXISTS', exitCode: 7, message, hint });\n }\n}\n\nexport class ValidationError extends RatioCLIError {\n constructor(message: string, hint?: string) {\n const opts: { code: string; exitCode: number; message: string; hint?: string } = {\n code: 'VALIDATION_ERROR',\n exitCode: 8,\n message,\n };\n if (hint !== undefined) opts.hint = hint;\n super(opts);\n }\n}\n\nexport class RelayResumeGapError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Reconnect from the current cursor; older events are outside the buffer window.',\n ) {\n super({ code: 'RELAY_RESUME_GAP', exitCode: 9, message, hint });\n }\n}\n\nexport class OrchestratorSpawnError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Could not start your app's dev command — check that the command in ratio.config.jsonc runs from this directory.\",\n ) {\n super({ code: 'ORCHESTRATOR_SPAWN', exitCode: 10, message, hint });\n }\n}\n\nexport class OrchestratorDrainTimeoutError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Your dev command did not exit cleanly within 5 seconds — it was forcibly terminated.',\n ) {\n super({ code: 'ORCHESTRATOR_DRAIN', exitCode: 11, message, hint });\n }\n}\n\nexport function isRatioCLIError(err: unknown): err is RatioCLIError {\n return err instanceof RatioCLIError;\n}\n","/**\n * Minimal verbose diagnostic seam for the auth flow.\n *\n * When `--verbose` is on, the auth HTTP/loopback boundaries emit ONE line\n * per boundary crossing (method, path, status, timing, flow events) so a\n * failing login/refresh can be diagnosed from the CLI transcript alone.\n *\n * Safety contract (enforced by tests):\n * - Lines go to STDERR only — never stdout — so `--json` consumers and\n * shell pipelines are never corrupted.\n * - NO secret material is ever passed to a tracer: no access/refresh\n * token, no authorization code, no code_verifier, no state value.\n * Only method, path, HTTP status, port numbers, and durations.\n */\n\n/** A tracer receives one pre-formatted diagnostic message per event. */\nexport type VerboseTracer = (msg: string) => void;\n\n/** Shared no-op used when `--verbose` is off (default everywhere). */\nexport const noopTracer: VerboseTracer = () => {};\n\n/**\n * Build a tracer. Returns the shared no-op unless `verbose` is true.\n * Output sink is injectable for tests; defaults to process.stderr.\n */\nexport function createVerboseTracer(\n verbose: boolean,\n write: (line: string) => void = (line) => {\n process.stderr.write(line);\n },\n): VerboseTracer {\n if (!verbose) return noopTracer;\n return (msg: string) => {\n write(`[verbose] ${msg}\\n`);\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,42 @@
1
+ import { RATIO_CLI_CLIENT_ID } from './config.js';
2
+
3
+ /**
4
+ * Pure URL builders for the OAuth authorization step.
5
+ *
6
+ * The browser flow uses a redirectUri (loopback callback) so the portal
7
+ * redirects back to the CLI. The manual flow uses ?display_code=1 so
8
+ * the portal renders a copyable code for the user to paste.
9
+ *
10
+ * Zero network I/O in this module — pure string construction only.
11
+ */
12
+
13
+ interface AuthorizeUrlBrowserOptions {
14
+ readonly serverUrl: string;
15
+ readonly clientId: typeof RATIO_CLI_CLIENT_ID;
16
+ readonly codeChallenge: string;
17
+ readonly state: string;
18
+ readonly redirectUri: string;
19
+ readonly scopes?: readonly string[];
20
+ readonly merchantId?: string;
21
+ }
22
+ interface AuthorizeUrlManualOptions {
23
+ readonly serverUrl: string;
24
+ readonly clientId: typeof RATIO_CLI_CLIENT_ID;
25
+ readonly codeChallenge: string;
26
+ readonly state: string;
27
+ readonly scopes?: readonly string[];
28
+ readonly merchantId?: string;
29
+ }
30
+ /**
31
+ * Build the authorize URL for the browser flow.
32
+ * Includes redirect_uri for the loopback callback; does NOT include display_code.
33
+ */
34
+ declare function buildBrowserAuthorizeUrl(o: AuthorizeUrlBrowserOptions): string;
35
+ /**
36
+ * Build the authorize URL for the manual copy-paste flow.
37
+ * Includes display_code=1 so the portal renders a copyable code.
38
+ * Does NOT include redirect_uri.
39
+ */
40
+ declare function buildManualAuthorizeUrl(o: AuthorizeUrlManualOptions): string;
41
+
42
+ export { type AuthorizeUrlBrowserOptions, type AuthorizeUrlManualOptions, buildBrowserAuthorizeUrl, buildManualAuthorizeUrl };
@@ -0,0 +1,35 @@
1
+ // src/lib/auth/config.ts
2
+ var RATIO_CLI_MERCHANT_ID = "ratio-cli-dev";
3
+
4
+ // src/lib/auth/authorize-url.ts
5
+ function buildBrowserAuthorizeUrl(o) {
6
+ const params = new URLSearchParams();
7
+ params.set("client_id", o.clientId);
8
+ params.set("merchant_id", o.merchantId ?? RATIO_CLI_MERCHANT_ID);
9
+ params.set("code_challenge", o.codeChallenge);
10
+ params.set("code_challenge_method", "S256");
11
+ params.set("state", o.state);
12
+ params.set("redirect_uri", o.redirectUri);
13
+ if (o.scopes && o.scopes.length > 0) {
14
+ params.set("scope", o.scopes.join(","));
15
+ }
16
+ return `${o.serverUrl}/oauth/authorize?${params.toString()}`;
17
+ }
18
+ function buildManualAuthorizeUrl(o) {
19
+ const params = new URLSearchParams();
20
+ params.set("client_id", o.clientId);
21
+ params.set("merchant_id", o.merchantId ?? RATIO_CLI_MERCHANT_ID);
22
+ params.set("code_challenge", o.codeChallenge);
23
+ params.set("code_challenge_method", "S256");
24
+ params.set("state", o.state);
25
+ params.set("display_code", "1");
26
+ if (o.scopes && o.scopes.length > 0) {
27
+ params.set("scope", o.scopes.join(","));
28
+ }
29
+ return `${o.serverUrl}/oauth/authorize?${params.toString()}`;
30
+ }
31
+ export {
32
+ buildBrowserAuthorizeUrl,
33
+ buildManualAuthorizeUrl
34
+ };
35
+ //# sourceMappingURL=authorize-url.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/lib/auth/config.ts","../../../src/lib/auth/authorize-url.ts"],"sourcesContent":["/**\n * Server-URL resolution for the Ratio CLI auth flow.\n *\n * Resolution order (highest priority first):\n * 1. RATIO_SERVER_URL environment variable\n * 2. Credentials.server from the stored credential file (passed in as opts.credentials.server)\n * 3. The compiled-in default: https://gkx.ratio.win\n *\n * The default is the ONLY place this URL appears in non-config source files.\n * All other auth-library modules call resolveServerUrl() — never hardcode.\n *\n * A-28: the merchantId constant lives here so it appears in exactly ONE\n * production file and is grep-able.\n */\n\n/** Compiled-in default server URL. */\nexport const DEFAULT_SERVER_URL = 'https://gkx.ratio.win';\n\n/**\n * Synthetic merchantId sent in the authorize URL.\n * A-28 adjudication: backend auto-creates missing merchants via\n * ensureMerchantExists(); 'ratio-cli-dev' is the designated synthetic value.\n * Appears in exactly ONE production file (this one).\n */\nexport const RATIO_CLI_MERCHANT_ID = 'ratio-cli-dev';\n\n/** The fixed clientId for the ratio-cli public OAuth client. */\nexport const RATIO_CLI_CLIENT_ID = 'ratio-cli' as const;\n\nexport interface ServerConfig {\n readonly url: string;\n}\n\nexport interface ResolveServerUrlOptions {\n readonly credentials?: { readonly server?: string };\n readonly env?: Readonly<Record<string, string | undefined>>;\n}\n\n/**\n * Resolve the backend server URL using the three-level precedence chain.\n * Injectable for tests: pass `env` to avoid reading `process.env` directly.\n */\nexport function resolveServerUrl(opts?: ResolveServerUrlOptions): string {\n const env = opts?.env ?? process.env;\n if (typeof env['RATIO_SERVER_URL'] === 'string' && env['RATIO_SERVER_URL'].length > 0) {\n return env['RATIO_SERVER_URL'];\n }\n if (typeof opts?.credentials?.server === 'string' && opts.credentials.server.length > 0) {\n return opts.credentials.server;\n }\n return DEFAULT_SERVER_URL;\n}\n","/**\n * Pure URL builders for the OAuth authorization step.\n *\n * The browser flow uses a redirectUri (loopback callback) so the portal\n * redirects back to the CLI. The manual flow uses ?display_code=1 so\n * the portal renders a copyable code for the user to paste.\n *\n * Zero network I/O in this module — pure string construction only.\n */\n\nimport { RATIO_CLI_CLIENT_ID, RATIO_CLI_MERCHANT_ID } from './config.js';\n\nexport interface AuthorizeUrlBrowserOptions {\n readonly serverUrl: string;\n readonly clientId: typeof RATIO_CLI_CLIENT_ID;\n readonly codeChallenge: string;\n readonly state: string;\n readonly redirectUri: string; // http://127.0.0.1:<port>/callback\n readonly scopes?: readonly string[];\n readonly merchantId?: string;\n}\n\nexport interface AuthorizeUrlManualOptions {\n readonly serverUrl: string;\n readonly clientId: typeof RATIO_CLI_CLIENT_ID;\n readonly codeChallenge: string;\n readonly state: string;\n readonly scopes?: readonly string[];\n readonly merchantId?: string;\n}\n\n/**\n * Build the authorize URL for the browser flow.\n * Includes redirect_uri for the loopback callback; does NOT include display_code.\n */\nexport function buildBrowserAuthorizeUrl(o: AuthorizeUrlBrowserOptions): string {\n const params = new URLSearchParams();\n params.set('client_id', o.clientId);\n params.set('merchant_id', o.merchantId ?? RATIO_CLI_MERCHANT_ID);\n params.set('code_challenge', o.codeChallenge);\n params.set('code_challenge_method', 'S256');\n params.set('state', o.state);\n params.set('redirect_uri', o.redirectUri);\n if (o.scopes && o.scopes.length > 0) {\n params.set('scope', o.scopes.join(','));\n }\n return `${o.serverUrl}/oauth/authorize?${params.toString()}`;\n}\n\n/**\n * Build the authorize URL for the manual copy-paste flow.\n * Includes display_code=1 so the portal renders a copyable code.\n * Does NOT include redirect_uri.\n */\nexport function buildManualAuthorizeUrl(o: AuthorizeUrlManualOptions): string {\n const params = new URLSearchParams();\n params.set('client_id', o.clientId);\n params.set('merchant_id', o.merchantId ?? RATIO_CLI_MERCHANT_ID);\n params.set('code_challenge', o.codeChallenge);\n params.set('code_challenge_method', 'S256');\n params.set('state', o.state);\n params.set('display_code', '1');\n if (o.scopes && o.scopes.length > 0) {\n params.set('scope', o.scopes.join(','));\n }\n return `${o.serverUrl}/oauth/authorize?${params.toString()}`;\n}\n"],"mappings":";AAwBO,IAAM,wBAAwB;;;ACW9B,SAAS,yBAAyB,GAAuC;AAC9E,QAAM,SAAS,IAAI,gBAAgB;AACnC,SAAO,IAAI,aAAa,EAAE,QAAQ;AAClC,SAAO,IAAI,eAAe,EAAE,cAAc,qBAAqB;AAC/D,SAAO,IAAI,kBAAkB,EAAE,aAAa;AAC5C,SAAO,IAAI,yBAAyB,MAAM;AAC1C,SAAO,IAAI,SAAS,EAAE,KAAK;AAC3B,SAAO,IAAI,gBAAgB,EAAE,WAAW;AACxC,MAAI,EAAE,UAAU,EAAE,OAAO,SAAS,GAAG;AACnC,WAAO,IAAI,SAAS,EAAE,OAAO,KAAK,GAAG,CAAC;AAAA,EACxC;AACA,SAAO,GAAG,EAAE,SAAS,oBAAoB,OAAO,SAAS,CAAC;AAC5D;AAOO,SAAS,wBAAwB,GAAsC;AAC5E,QAAM,SAAS,IAAI,gBAAgB;AACnC,SAAO,IAAI,aAAa,EAAE,QAAQ;AAClC,SAAO,IAAI,eAAe,EAAE,cAAc,qBAAqB;AAC/D,SAAO,IAAI,kBAAkB,EAAE,aAAa;AAC5C,SAAO,IAAI,yBAAyB,MAAM;AAC1C,SAAO,IAAI,SAAS,EAAE,KAAK;AAC3B,SAAO,IAAI,gBAAgB,GAAG;AAC9B,MAAI,EAAE,UAAU,EAAE,OAAO,SAAS,GAAG;AACnC,WAAO,IAAI,SAAS,EAAE,OAAO,KAAK,GAAG,CAAC;AAAA,EACxC;AACA,SAAO,GAAG,EAAE,SAAS,oBAAoB,OAAO,SAAS,CAAC;AAC5D;","names":[]}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Browser-launch wrapper for the OAuth browser flow.
3
+ *
4
+ * This is the SOLE file that imports from the 'open' npm package.
5
+ * Injectable via the BrowserOpener interface for unit tests.
6
+ */
7
+ interface BrowserOpener {
8
+ open(url: string): Promise<void>;
9
+ }
10
+ /**
11
+ * Create the default browser opener backed by the 'open' package.
12
+ * Throws NotLoggedInError on failure with the --manual fallback hint.
13
+ */
14
+ declare function defaultBrowserOpener(): BrowserOpener;
15
+
16
+ export { type BrowserOpener, defaultBrowserOpener };
@@ -0,0 +1,48 @@
1
+ // src/lib/errors.ts
2
+ var RatioCLIError = class extends Error {
3
+ code;
4
+ exitCode;
5
+ hint;
6
+ docsUrl;
7
+ constructor(opts) {
8
+ super(opts.message);
9
+ this.name = new.target.name;
10
+ this.code = opts.code;
11
+ this.exitCode = opts.exitCode;
12
+ if (opts.hint !== void 0) this.hint = opts.hint;
13
+ if (opts.docsUrl !== void 0) this.docsUrl = opts.docsUrl;
14
+ }
15
+ };
16
+ var NotLoggedInError = class extends RatioCLIError {
17
+ constructor(message, hint = "Run 'ratio auth login' to sign in.") {
18
+ super({ code: "NOT_LOGGED_IN", exitCode: 3, message, hint });
19
+ }
20
+ };
21
+
22
+ // src/lib/auth/browser.ts
23
+ function defaultBrowserOpener() {
24
+ return {
25
+ async open(url) {
26
+ let openFn;
27
+ try {
28
+ const mod = await import("open");
29
+ openFn = mod.default;
30
+ } catch {
31
+ throw new NotLoggedInError(
32
+ "Could not open a browser. Run 'ratio auth login --manual' to complete without a browser."
33
+ );
34
+ }
35
+ try {
36
+ await openFn(url);
37
+ } catch {
38
+ throw new NotLoggedInError(
39
+ "Could not open a browser. Run 'ratio auth login --manual' to complete without a browser."
40
+ );
41
+ }
42
+ }
43
+ };
44
+ }
45
+ export {
46
+ defaultBrowserOpener
47
+ };
48
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/lib/errors.ts","../../../src/lib/auth/browser.ts"],"sourcesContent":["/**\n * Error hierarchy for the Ratio CLI. Every error thrown from a command's\n * run() is expected to be an instance of RatioCLIError so the global\n * render-error handler can map it to a deterministic stderr line and exit\n * code. Uncaught Error subclasses render as \"INTERNAL: <msg>\" with exit 1.\n *\n * No import from @oclif/core — this file is dependency-free so tests\n * can import it without oclif overhead.\n */\n\nexport class RatioCLIError extends Error {\n readonly code: string;\n readonly exitCode: number;\n readonly hint?: string;\n readonly docsUrl?: string;\n\n constructor(opts: {\n code: string;\n exitCode: number;\n message: string;\n hint?: string;\n docsUrl?: string;\n }) {\n super(opts.message);\n this.name = new.target.name;\n this.code = opts.code;\n this.exitCode = opts.exitCode;\n if (opts.hint !== undefined) this.hint = opts.hint;\n if (opts.docsUrl !== undefined) this.docsUrl = opts.docsUrl;\n }\n}\n\n/** Coming-soon stub UX. Renders on stdout with exit 0. */\nexport class ComingSoonError extends RatioCLIError {\n constructor(message: string) {\n super({ code: 'COMING_SOON', exitCode: 0, message });\n }\n}\n\n/** User has no credentials and command requires auth. */\nexport class NotLoggedInError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Run 'ratio auth login' to sign in.\",\n ) {\n super({ code: 'NOT_LOGGED_IN', exitCode: 3, message, hint });\n }\n}\n\nexport class TokenExpiredError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Run 'ratio auth login' to refresh your session.\",\n ) {\n super({ code: 'TOKEN_EXPIRED', exitCode: 3, message, hint });\n }\n}\n\nexport class ScopeDeniedError extends RatioCLIError {\n constructor(message: string, hint?: string) {\n const opts: { code: string; exitCode: number; message: string; hint?: string } = {\n code: 'SCOPE_DENIED',\n exitCode: 4,\n message,\n };\n if (hint !== undefined) opts.hint = hint;\n super(opts);\n }\n}\n\nexport class NetworkError extends RatioCLIError {\n constructor(message: string, hint: string = 'Check your network and retry.') {\n super({ code: 'NETWORK_ERROR', exitCode: 5, message, hint });\n }\n}\n\nexport class RelayConnectError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Verify the tunnel URL and that the auth-server is reachable.',\n ) {\n super({ code: 'RELAY_CONNECT', exitCode: 6, message, hint });\n }\n}\n\nexport class TemplateExistsError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Choose a different name or remove the existing directory.',\n ) {\n super({ code: 'TEMPLATE_EXISTS', exitCode: 7, message, hint });\n }\n}\n\nexport class ValidationError extends RatioCLIError {\n constructor(message: string, hint?: string) {\n const opts: { code: string; exitCode: number; message: string; hint?: string } = {\n code: 'VALIDATION_ERROR',\n exitCode: 8,\n message,\n };\n if (hint !== undefined) opts.hint = hint;\n super(opts);\n }\n}\n\nexport class RelayResumeGapError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Reconnect from the current cursor; older events are outside the buffer window.',\n ) {\n super({ code: 'RELAY_RESUME_GAP', exitCode: 9, message, hint });\n }\n}\n\nexport class OrchestratorSpawnError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = \"Could not start your app's dev command — check that the command in ratio.config.jsonc runs from this directory.\",\n ) {\n super({ code: 'ORCHESTRATOR_SPAWN', exitCode: 10, message, hint });\n }\n}\n\nexport class OrchestratorDrainTimeoutError extends RatioCLIError {\n constructor(\n message: string,\n hint: string = 'Your dev command did not exit cleanly within 5 seconds — it was forcibly terminated.',\n ) {\n super({ code: 'ORCHESTRATOR_DRAIN', exitCode: 11, message, hint });\n }\n}\n\nexport function isRatioCLIError(err: unknown): err is RatioCLIError {\n return err instanceof RatioCLIError;\n}\n","/**\n * Browser-launch wrapper for the OAuth browser flow.\n *\n * This is the SOLE file that imports from the 'open' npm package.\n * Injectable via the BrowserOpener interface for unit tests.\n */\n\nimport { NotLoggedInError } from '../errors.js';\n\nexport interface BrowserOpener {\n open(url: string): Promise<void>;\n}\n\n/**\n * Create the default browser opener backed by the 'open' package.\n * Throws NotLoggedInError on failure with the --manual fallback hint.\n */\nexport function defaultBrowserOpener(): BrowserOpener {\n return {\n async open(url: string): Promise<void> {\n // Dynamic import is used so the module is loadable in test environments\n // that do not have a display (CI / SSH). The real 'open' call only fires\n // in production use when this opener is not replaced by a test double.\n let openFn: (url: string) => Promise<unknown>;\n try {\n const mod = await import('open');\n openFn = mod.default as (url: string) => Promise<unknown>;\n } catch {\n throw new NotLoggedInError(\n \"Could not open a browser. Run 'ratio auth login --manual' to complete without a browser.\",\n );\n }\n try {\n await openFn(url);\n } catch {\n throw new NotLoggedInError(\n \"Could not open a browser. Run 'ratio auth login --manual' to complete without a browser.\",\n );\n }\n },\n };\n}\n"],"mappings":";AAUO,IAAM,gBAAN,cAA4B,MAAM;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,YAAY,MAMT;AACD,UAAM,KAAK,OAAO;AAClB,SAAK,OAAO,WAAW;AACvB,SAAK,OAAO,KAAK;AACjB,SAAK,WAAW,KAAK;AACrB,QAAI,KAAK,SAAS,OAAW,MAAK,OAAO,KAAK;AAC9C,QAAI,KAAK,YAAY,OAAW,MAAK,UAAU,KAAK;AAAA,EACtD;AACF;AAUO,IAAM,mBAAN,cAA+B,cAAc;AAAA,EAClD,YACE,SACA,OAAe,sCACf;AACA,UAAM,EAAE,MAAM,iBAAiB,UAAU,GAAG,SAAS,KAAK,CAAC;AAAA,EAC7D;AACF;;;AC9BO,SAAS,uBAAsC;AACpD,SAAO;AAAA,IACL,MAAM,KAAK,KAA4B;AAIrC,UAAI;AACJ,UAAI;AACF,cAAM,MAAM,MAAM,OAAO,MAAM;AAC/B,iBAAS,IAAI;AAAA,MACf,QAAQ;AACN,cAAM,IAAI;AAAA,UACR;AAAA,QACF;AAAA,MACF;AACA,UAAI;AACF,cAAM,OAAO,GAAG;AAAA,MAClB,QAAQ;AACN,cAAM,IAAI;AAAA,UACR;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Server-URL resolution for the Ratio CLI auth flow.
3
+ *
4
+ * Resolution order (highest priority first):
5
+ * 1. RATIO_SERVER_URL environment variable
6
+ * 2. Credentials.server from the stored credential file (passed in as opts.credentials.server)
7
+ * 3. The compiled-in default: https://gkx.ratio.win
8
+ *
9
+ * The default is the ONLY place this URL appears in non-config source files.
10
+ * All other auth-library modules call resolveServerUrl() — never hardcode.
11
+ *
12
+ * A-28: the merchantId constant lives here so it appears in exactly ONE
13
+ * production file and is grep-able.
14
+ */
15
+ /** Compiled-in default server URL. */
16
+ declare const DEFAULT_SERVER_URL = "https://gkx.ratio.win";
17
+ /**
18
+ * Synthetic merchantId sent in the authorize URL.
19
+ * A-28 adjudication: backend auto-creates missing merchants via
20
+ * ensureMerchantExists(); 'ratio-cli-dev' is the designated synthetic value.
21
+ * Appears in exactly ONE production file (this one).
22
+ */
23
+ declare const RATIO_CLI_MERCHANT_ID = "ratio-cli-dev";
24
+ /** The fixed clientId for the ratio-cli public OAuth client. */
25
+ declare const RATIO_CLI_CLIENT_ID: "ratio-cli";
26
+ interface ServerConfig {
27
+ readonly url: string;
28
+ }
29
+ interface ResolveServerUrlOptions {
30
+ readonly credentials?: {
31
+ readonly server?: string;
32
+ };
33
+ readonly env?: Readonly<Record<string, string | undefined>>;
34
+ }
35
+ /**
36
+ * Resolve the backend server URL using the three-level precedence chain.
37
+ * Injectable for tests: pass `env` to avoid reading `process.env` directly.
38
+ */
39
+ declare function resolveServerUrl(opts?: ResolveServerUrlOptions): string;
40
+
41
+ export { DEFAULT_SERVER_URL, RATIO_CLI_CLIENT_ID, RATIO_CLI_MERCHANT_ID, type ResolveServerUrlOptions, type ServerConfig, resolveServerUrl };
@@ -0,0 +1,21 @@
1
+ // src/lib/auth/config.ts
2
+ var DEFAULT_SERVER_URL = "https://gkx.ratio.win";
3
+ var RATIO_CLI_MERCHANT_ID = "ratio-cli-dev";
4
+ var RATIO_CLI_CLIENT_ID = "ratio-cli";
5
+ function resolveServerUrl(opts) {
6
+ const env = opts?.env ?? process.env;
7
+ if (typeof env["RATIO_SERVER_URL"] === "string" && env["RATIO_SERVER_URL"].length > 0) {
8
+ return env["RATIO_SERVER_URL"];
9
+ }
10
+ if (typeof opts?.credentials?.server === "string" && opts.credentials.server.length > 0) {
11
+ return opts.credentials.server;
12
+ }
13
+ return DEFAULT_SERVER_URL;
14
+ }
15
+ export {
16
+ DEFAULT_SERVER_URL,
17
+ RATIO_CLI_CLIENT_ID,
18
+ RATIO_CLI_MERCHANT_ID,
19
+ resolveServerUrl
20
+ };
21
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/lib/auth/config.ts"],"sourcesContent":["/**\n * Server-URL resolution for the Ratio CLI auth flow.\n *\n * Resolution order (highest priority first):\n * 1. RATIO_SERVER_URL environment variable\n * 2. Credentials.server from the stored credential file (passed in as opts.credentials.server)\n * 3. The compiled-in default: https://gkx.ratio.win\n *\n * The default is the ONLY place this URL appears in non-config source files.\n * All other auth-library modules call resolveServerUrl() — never hardcode.\n *\n * A-28: the merchantId constant lives here so it appears in exactly ONE\n * production file and is grep-able.\n */\n\n/** Compiled-in default server URL. */\nexport const DEFAULT_SERVER_URL = 'https://gkx.ratio.win';\n\n/**\n * Synthetic merchantId sent in the authorize URL.\n * A-28 adjudication: backend auto-creates missing merchants via\n * ensureMerchantExists(); 'ratio-cli-dev' is the designated synthetic value.\n * Appears in exactly ONE production file (this one).\n */\nexport const RATIO_CLI_MERCHANT_ID = 'ratio-cli-dev';\n\n/** The fixed clientId for the ratio-cli public OAuth client. */\nexport const RATIO_CLI_CLIENT_ID = 'ratio-cli' as const;\n\nexport interface ServerConfig {\n readonly url: string;\n}\n\nexport interface ResolveServerUrlOptions {\n readonly credentials?: { readonly server?: string };\n readonly env?: Readonly<Record<string, string | undefined>>;\n}\n\n/**\n * Resolve the backend server URL using the three-level precedence chain.\n * Injectable for tests: pass `env` to avoid reading `process.env` directly.\n */\nexport function resolveServerUrl(opts?: ResolveServerUrlOptions): string {\n const env = opts?.env ?? process.env;\n if (typeof env['RATIO_SERVER_URL'] === 'string' && env['RATIO_SERVER_URL'].length > 0) {\n return env['RATIO_SERVER_URL'];\n }\n if (typeof opts?.credentials?.server === 'string' && opts.credentials.server.length > 0) {\n return opts.credentials.server;\n }\n return DEFAULT_SERVER_URL;\n}\n"],"mappings":";AAgBO,IAAM,qBAAqB;AAQ3B,IAAM,wBAAwB;AAG9B,IAAM,sBAAsB;AAe5B,SAAS,iBAAiB,MAAwC;AACvE,QAAM,MAAM,MAAM,OAAO,QAAQ;AACjC,MAAI,OAAO,IAAI,kBAAkB,MAAM,YAAY,IAAI,kBAAkB,EAAE,SAAS,GAAG;AACrF,WAAO,IAAI,kBAAkB;AAAA,EAC/B;AACA,MAAI,OAAO,MAAM,aAAa,WAAW,YAAY,KAAK,YAAY,OAAO,SAAS,GAAG;AACvF,WAAO,KAAK,YAAY;AAAA,EAC1B;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,30 @@
1
+ import { TokenSet } from '../credentials/types.js';
2
+ import { TokenRefresher } from '../credentials/refresher.js';
3
+ import { RATIO_CLI_CLIENT_ID } from './config.js';
4
+ import { VerboseTracer } from './verbose-trace.js';
5
+
6
+ /**
7
+ * HTTP implementation of the TokenRefresher interface.
8
+ *
9
+ * POSTs to /oauth/token with grant_type=refresh_token.
10
+ * Maps 4xx → TokenExpiredError, 5xx/network → NetworkError.
11
+ * Never logs or echoes the token value in any error message.
12
+ */
13
+
14
+ interface HttpTokenRefresherOptions {
15
+ readonly serverUrl: string;
16
+ readonly clientId: typeof RATIO_CLI_CLIENT_ID;
17
+ readonly fetchImpl?: typeof fetch;
18
+ /** Verbose diagnostic sink — never receives the refresh token value. */
19
+ readonly trace?: VerboseTracer;
20
+ }
21
+ declare class HttpTokenRefresher implements TokenRefresher {
22
+ private readonly serverUrl;
23
+ private readonly clientId;
24
+ private readonly fetchImpl;
25
+ private readonly trace;
26
+ constructor(opts: HttpTokenRefresherOptions);
27
+ refresh(refreshToken: string): Promise<TokenSet>;
28
+ }
29
+
30
+ export { HttpTokenRefresher, type HttpTokenRefresherOptions };