@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,115 @@
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 ValidationError = class extends RatioCLIError {
17
+ constructor(message, hint) {
18
+ const opts = {
19
+ code: "VALIDATION_ERROR",
20
+ exitCode: 8,
21
+ message
22
+ };
23
+ if (hint !== void 0) opts.hint = hint;
24
+ super(opts);
25
+ }
26
+ };
27
+ function isRatioCLIError(err) {
28
+ return err instanceof RatioCLIError;
29
+ }
30
+
31
+ // src/lib/render-error.ts
32
+ function isOclifParserError(err) {
33
+ return err instanceof Error && "parse" in err && err.parse !== null && err.parse !== void 0;
34
+ }
35
+ function buildErrorEnvelope(code, message, hint, docsUrl) {
36
+ const obj = { ok: false, code, message };
37
+ if (hint !== void 0) obj["hint"] = hint;
38
+ if (docsUrl !== void 0) obj["docsUrl"] = docsUrl;
39
+ return JSON.stringify(obj) + "\n";
40
+ }
41
+ function renderError(input) {
42
+ const { err, verbose, json } = input;
43
+ if (isRatioCLIError(err)) {
44
+ const ratioErr = err;
45
+ if (ratioErr.exitCode === 0) {
46
+ if (json) {
47
+ return {
48
+ stdout: JSON.stringify({ ok: true, data: null }) + "\n",
49
+ stderr: "",
50
+ exitCode: 0
51
+ };
52
+ }
53
+ return {
54
+ stdout: ratioErr.message + "\n",
55
+ stderr: "",
56
+ exitCode: 0
57
+ };
58
+ }
59
+ let stderrLines = `${ratioErr.code}: ${ratioErr.message}
60
+ `;
61
+ if (ratioErr.hint !== void 0) stderrLines += `${ratioErr.hint}
62
+ `;
63
+ if (ratioErr.docsUrl !== void 0) stderrLines += `${ratioErr.docsUrl}
64
+ `;
65
+ if (json) {
66
+ return {
67
+ stdout: buildErrorEnvelope(ratioErr.code, ratioErr.message, ratioErr.hint, ratioErr.docsUrl),
68
+ stderr: verbose && ratioErr.stack ? ratioErr.stack + "\n" : "",
69
+ exitCode: ratioErr.exitCode
70
+ };
71
+ }
72
+ if (verbose && ratioErr.stack) {
73
+ stderrLines += ratioErr.stack + "\n";
74
+ }
75
+ return { stdout: "", stderr: stderrLines, exitCode: ratioErr.exitCode };
76
+ }
77
+ if (isOclifParserError(err)) {
78
+ const parserErr = err;
79
+ const msg = parserErr.message || "Unknown parse error";
80
+ const coercedErr = new ValidationError(msg);
81
+ let stderrText2 = `${coercedErr.code}: ${msg}
82
+ `;
83
+ if (json) {
84
+ return {
85
+ stdout: buildErrorEnvelope(coercedErr.code, msg),
86
+ stderr: verbose && parserErr.stack ? parserErr.stack + "\n" : "",
87
+ exitCode: 8
88
+ };
89
+ }
90
+ if (verbose && parserErr.stack) {
91
+ stderrText2 += parserErr.stack + "\n";
92
+ }
93
+ return { stdout: "", stderr: stderrText2, exitCode: 8 };
94
+ }
95
+ const message = err instanceof Error ? err.message : String(err);
96
+ const stack = err instanceof Error ? err.stack : void 0;
97
+ const stderrPrefix = `INTERNAL: ${message}
98
+ `;
99
+ if (json) {
100
+ return {
101
+ stdout: buildErrorEnvelope("INTERNAL", message),
102
+ stderr: verbose && stack ? stack + "\n" : "",
103
+ exitCode: 1
104
+ };
105
+ }
106
+ let stderrText = stderrPrefix;
107
+ if (verbose && stack) {
108
+ stderrText += stack + "\n";
109
+ }
110
+ return { stdout: "", stderr: stderrText, exitCode: 1 };
111
+ }
112
+ export {
113
+ renderError
114
+ };
115
+ //# sourceMappingURL=render-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/lib/errors.ts","../../src/lib/render-error.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","import { isRatioCLIError, RatioCLIError, ValidationError } from './errors.js';\n\nexport interface RenderInput {\n err: unknown;\n verbose: boolean;\n json: boolean;\n}\n\nexport interface RenderOutput {\n /** Text to write to stdout. Empty string means nothing to write. */\n stdout: string;\n /** Text to write to stderr. Empty string means nothing to write. */\n stderr: string;\n exitCode: number;\n}\n\n/**\n * Detects whether an error originates from the oclif argument/flag parser.\n * oclif's CLIParseError subclasses (NonExistentFlagsError, RequiredArgsError,\n * etc.) all carry a `parse` property set by CLIParseErrorOptions. This is the\n * stable structural signal — more reliable than checking the class name string.\n */\nfunction isOclifParserError(err: unknown): err is Error & { parse: unknown } {\n return (\n err instanceof Error &&\n 'parse' in err &&\n err.parse !== null &&\n err.parse !== undefined\n );\n}\n\n/**\n * Builds the JSON error envelope. Fields with undefined values are omitted\n * so the consumer never sees `\"hint\": null` or `\"docsUrl\": null`.\n */\nfunction buildErrorEnvelope(\n code: string,\n message: string,\n hint?: string,\n docsUrl?: string,\n): string {\n const obj: Record<string, unknown> = { ok: false, code, message };\n if (hint !== undefined) obj['hint'] = hint;\n if (docsUrl !== undefined) obj['docsUrl'] = docsUrl;\n return JSON.stringify(obj) + '\\n';\n}\n\n/**\n * Pure rendering function — no side effects, no process.exit, no I/O.\n * The caller (RatioCommand.catch) is responsible for writing output and exiting.\n *\n * Rendering rules (all branches):\n *\n * | Error shape | --json | --verbose | stdout | stderr | exit |\n * |----------------------|--------|-----------|--------------------------------|----------------------------------|------|\n * | ComingSoonError | false | any | err.message + '\\n' | '' | 0 |\n * | ComingSoonError | true | any | '{\"ok\":true,\"data\":null}\\n' | '' | 0 |\n * | RatioCLIError >0 | false | false | '' | code: msg [+ hint] [+ docsUrl] | N |\n * | RatioCLIError >0 | false | true | '' | above + stack | N |\n * | RatioCLIError >0 | true | false | JSON envelope | '' | N |\n * | RatioCLIError >0 | true | true | JSON envelope | stack only | N |\n * | oclif ParserError | false | any | '' | VALIDATION_ERROR: msg [+stack] | 8 |\n * | oclif ParserError | true | any | JSON VALIDATION_ERROR envelope | stack if verbose | 8 |\n * | Plain Error / other | false | false | '' | INTERNAL: msg\\n | 1 |\n * | Plain Error / other | false | true | '' | INTERNAL: msg\\n + stack\\n | 1 |\n * | Plain Error / other | true | any | JSON INTERNAL envelope | stack if verbose | 1 |\n */\nexport function renderError(input: RenderInput): RenderOutput {\n const { err, verbose, json } = input;\n\n // ── Branch 1: our own typed hierarchy ───────────────────────────────────\n if (isRatioCLIError(err)) {\n const ratioErr = err as RatioCLIError;\n\n // exitCode === 0 → coming-soon: render on stdout, no stderr, exit 0\n if (ratioErr.exitCode === 0) {\n if (json) {\n return {\n stdout: JSON.stringify({ ok: true, data: null }) + '\\n',\n stderr: '',\n exitCode: 0,\n };\n }\n return {\n stdout: ratioErr.message + '\\n',\n stderr: '',\n exitCode: 0,\n };\n }\n\n // exitCode > 0 → error render\n let stderrLines = `${ratioErr.code}: ${ratioErr.message}\\n`;\n if (ratioErr.hint !== undefined) stderrLines += `${ratioErr.hint}\\n`;\n if (ratioErr.docsUrl !== undefined) stderrLines += `${ratioErr.docsUrl}\\n`;\n\n if (json) {\n return {\n stdout: buildErrorEnvelope(ratioErr.code, ratioErr.message, ratioErr.hint, ratioErr.docsUrl),\n stderr: verbose && ratioErr.stack ? ratioErr.stack + '\\n' : '',\n exitCode: ratioErr.exitCode,\n };\n }\n\n if (verbose && ratioErr.stack) {\n stderrLines += ratioErr.stack + '\\n';\n }\n\n return { stdout: '', stderr: stderrLines, exitCode: ratioErr.exitCode };\n }\n\n // ── Branch 2: oclif parser error (unknown flag, missing required arg, etc.) ──\n if (isOclifParserError(err)) {\n const parserErr = err as Error & { parse: unknown };\n const msg = parserErr.message || 'Unknown parse error';\n\n // Strip oclif's \"Unexpected argument: --foo\" or similar prefix noise where\n // possible, but keep the core message as-is since it's already human-readable.\n const coercedErr = new ValidationError(msg);\n\n let stderrText = `${coercedErr.code}: ${msg}\\n`;\n\n if (json) {\n return {\n stdout: buildErrorEnvelope(coercedErr.code, msg),\n stderr: verbose && parserErr.stack ? parserErr.stack + '\\n' : '',\n exitCode: 8,\n };\n }\n\n if (verbose && parserErr.stack) {\n stderrText += parserErr.stack + '\\n';\n }\n\n return { stdout: '', stderr: stderrText, exitCode: 8 };\n }\n\n // ── Branch 3: plain Error or non-Error throw ─────────────────────────────\n const message = err instanceof Error ? err.message : String(err);\n const stack = err instanceof Error ? err.stack : undefined;\n const stderrPrefix = `INTERNAL: ${message}\\n`;\n\n if (json) {\n return {\n stdout: buildErrorEnvelope('INTERNAL', message),\n stderr: verbose && stack ? stack + '\\n' : '',\n exitCode: 1,\n };\n }\n\n let stderrText = stderrPrefix;\n if (verbose && stack) {\n stderrText += stack + '\\n';\n }\n\n return { stdout: '', stderr: stderrText, exitCode: 1 };\n}\n"],"mappings":"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","names":["stderrText"]}
@@ -0,0 +1,12 @@
1
+ import * as _oclif_core_interfaces from '@oclif/core/interfaces';
2
+
3
+ /** Global verbose flag — every command imports and spreads this. */
4
+ declare const verboseFlag: {
5
+ verbose: _oclif_core_interfaces.BooleanFlag<boolean>;
6
+ };
7
+ /** JSON envelope flag — read-style commands (whoami, dev trigger --list) import. */
8
+ declare const jsonFlag: {
9
+ json: _oclif_core_interfaces.BooleanFlag<boolean>;
10
+ };
11
+
12
+ export { jsonFlag, verboseFlag };
@@ -0,0 +1,17 @@
1
+ // src/lib/verbose-flag.ts
2
+ import { Flags } from "@oclif/core";
3
+ var verboseFlag = {
4
+ verbose: Flags.boolean({
5
+ description: "Show diagnostic detail: HTTP status/timing lines for auth requests (stderr) and stack traces for internal errors."
6
+ })
7
+ };
8
+ var jsonFlag = {
9
+ json: Flags.boolean({
10
+ description: "Emit machine-readable JSON on stdout."
11
+ })
12
+ };
13
+ export {
14
+ jsonFlag,
15
+ verboseFlag
16
+ };
17
+ //# sourceMappingURL=verbose-flag.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/lib/verbose-flag.ts"],"sourcesContent":["import { Flags } from '@oclif/core';\n\n/** Global verbose flag — every command imports and spreads this. */\nexport const verboseFlag = {\n verbose: Flags.boolean({\n description:\n 'Show diagnostic detail: HTTP status/timing lines for auth requests (stderr) and stack traces for internal errors.',\n }),\n};\n\n/** JSON envelope flag — read-style commands (whoami, dev trigger --list) import. */\nexport const jsonFlag = {\n json: Flags.boolean({\n description: 'Emit machine-readable JSON on stdout.',\n }),\n};\n"],"mappings":";AAAA,SAAS,aAAa;AAGf,IAAM,cAAc;AAAA,EACzB,SAAS,MAAM,QAAQ;AAAA,IACrB,aACE;AAAA,EACJ,CAAC;AACH;AAGO,IAAM,WAAW;AAAA,EACtB,MAAM,MAAM,QAAQ;AAAA,IAClB,aAAa;AAAA,EACf,CAAC;AACH;","names":[]}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Template scaffold engine for `ratio app create`.
3
+ *
4
+ * Renders a bundled app template into a destination directory:
5
+ *
6
+ * - Files ending in `.tmpl` are read as UTF-8 and every `{{key}}`
7
+ * placeholder is substituted from the provided substitution map,
8
+ * then written with the `.tmpl` suffix stripped
9
+ * (`package.json.tmpl` → `package.json`).
10
+ * - All other files are copied byte-for-byte.
11
+ *
12
+ * The engine is intentionally minimal: a single-pass
13
+ * `String.replace(/\{\{key\}\}/g, …)` with a replacer FUNCTION (so
14
+ * `$&`, `$1`, `$$` in substitution values are never interpreted as
15
+ * special replacement patterns). There is no template library and no
16
+ * recursive substitution — if a substitution value itself contains
17
+ * `{{...}}`, that literal text appears in the output untouched.
18
+ *
19
+ * Every template root carries a `manifest.json`:
20
+ *
21
+ * ```json
22
+ * {
23
+ * "name": "minimal",
24
+ * "description": "Express-based Ratio app (default).",
25
+ * "substitutionKeys": ["appName", "clientId", "sdkVersion", "templateName"]
26
+ * }
27
+ * ```
28
+ *
29
+ * `substitutionKeys` is the closed set of placeholders the template's
30
+ * `.tmpl` files may use. A `{{key}}` found in a `.tmpl` file that is
31
+ * not declared in the manifest is a render-time error
32
+ * (`unknown_substitution_key`). The `manifest.json` itself is copied
33
+ * verbatim into the generated app so tooling can re-inspect which
34
+ * template produced it.
35
+ *
36
+ * The canonical `ratio.config.jsonc` produced by every template has
37
+ * this shape (the scaffolder writes it; `ratio app link` later fills
38
+ * `clientId`):
39
+ *
40
+ * ```jsonc
41
+ * {
42
+ * // Canonical Ratio app manifest. Committed to the app repo.
43
+ * // Written by scaffolder; `clientId` filled by `ratio app link`.
44
+ * "$schema": "https://ratio.app/schema/app-manifest.v1.json",
45
+ * "appName": "{{appName}}",
46
+ * "clientId": "{{clientId}}",
47
+ * "sdkVersion": "{{sdkVersion}}",
48
+ * "template": "{{templateName}}",
49
+ * "webhooks": {
50
+ * "path": "/webhooks",
51
+ * "topics": []
52
+ * },
53
+ * "oauth": {
54
+ * "redirectPath": "/oauth/callback",
55
+ * "scopes": []
56
+ * }
57
+ * }
58
+ * ```
59
+ *
60
+ * The engine does not hardcode that shape — it only substitutes keys —
61
+ * but the block above documents the contract for template authors.
62
+ */
63
+ /** Per-template metadata, loaded from `<templateRoot>/manifest.json`. */
64
+ interface TemplateManifest {
65
+ /** Template identifier, e.g. 'minimal' | 'with-admin-ui' | 'serverless'. */
66
+ readonly name: string;
67
+ readonly description: string;
68
+ /** Closed set of `{{key}}` placeholders the template's `.tmpl` files may use. */
69
+ readonly substitutionKeys: readonly string[];
70
+ }
71
+ interface RenderInput {
72
+ /** Must match one of the discovered templates. */
73
+ readonly templateName: string;
74
+ /** Absolute path to write into. Must be empty (or not yet exist). */
75
+ readonly destinationDir: string;
76
+ readonly substitutions: Readonly<Record<string, string>>;
77
+ /** Overrides template-root discovery (test injection). */
78
+ readonly sourceRootOverride?: string;
79
+ /** Called once per file AFTER a successful write. Bytes = per-file, NOT cumulative. */
80
+ readonly onFile?: (relativePath: string, bytesWritten: number) => void;
81
+ }
82
+ interface RenderResult {
83
+ /** Absolute paths, in write order. */
84
+ readonly filesWritten: readonly string[];
85
+ readonly bytesWritten: number;
86
+ }
87
+ type RatioTemplateErrorCode = 'template_not_found' | 'unknown_substitution_key' | 'missing_substitution_value' | 'destination_not_empty' | 'io_error';
88
+ /** Error surface for the scaffold engine. All failures carry a stable `code`. */
89
+ declare class RatioTemplateError extends Error {
90
+ readonly code: RatioTemplateErrorCode;
91
+ readonly templateName?: string;
92
+ readonly key?: string;
93
+ constructor(opts: {
94
+ readonly code: RatioTemplateErrorCode;
95
+ readonly message: string;
96
+ readonly templateName?: string;
97
+ readonly key?: string;
98
+ readonly cause?: unknown;
99
+ });
100
+ }
101
+ /**
102
+ * Discover all bundled templates and return their manifests, sorted by
103
+ * template directory name for a stable order.
104
+ */
105
+ declare function listTemplates(sourceRootOverride?: string): Promise<readonly TemplateManifest[]>;
106
+ /**
107
+ * Render the named template into `destinationDir`.
108
+ *
109
+ * Strict about its destination: if the directory already contains any
110
+ * entry the render throws `destination_not_empty` — the caller owns
111
+ * the "directory already has content" UX.
112
+ */
113
+ declare function renderTemplate(input: RenderInput): Promise<RenderResult>;
114
+
115
+ export { type RenderInput as R, type TemplateManifest as T, type RenderResult as a, RatioTemplateError as b, listTemplates as l, renderTemplate as r };
File without changes
@@ -0,0 +1,4 @@
1
+ # Ratio app secrets — do not commit real values.
2
+ RATIO_CLIENT_SECRET=
3
+ RATIO_WEBHOOK_SECRET=
4
+ PORT=3000
@@ -0,0 +1,26 @@
1
+ # {{appName}}
2
+
3
+ Scaffolded from the Ratio `{{templateName}}` template — an Express-based
4
+ Ratio app with OAuth install and verified webhooks wired up via
5
+ `@ratio-app/sdk`.
6
+
7
+ ## Getting started
8
+
9
+ 1. Install dependencies: `npm install`.
10
+ 2. Create your Ratio app manually in the Ratio developer dashboard and copy the client id.
11
+ 3. Open `ratio.config.jsonc` and paste the client id into the `clientId` field (or run `ratio app link` to auto-fill it).
12
+ 4. Copy `.env.example` to `.env` and fill in `RATIO_CLIENT_SECRET` and `RATIO_WEBHOOK_SECRET` from the dashboard.
13
+ 5. Run `npm run dev` to start the local server on port 3000.
14
+ 6. Point your merchant test store at `https://<merchant>.<store>/oauth/callback` and `https://<merchant>.<store>/webhooks` (use a public tunnel such as ngrok or `ratio dev listen` for local testing).
15
+
16
+ ## Project layout
17
+
18
+ - `src/index.ts` — Express bootstrap; mounts the install and webhook routes.
19
+ - `src/install.ts` — OAuth install callback (`GET /oauth/callback`); exchanges the authorization code for tokens.
20
+ - `src/webhooks.ts` — webhook receiver (`POST /webhooks`); verifies the HMAC signature over the raw body, then dispatches by topic.
21
+ - `ratio.config.jsonc` — the app manifest committed to this repo.
22
+
23
+ ## Notes
24
+
25
+ - The webhook route uses `express.raw({ type: 'application/json' })` on purpose: signature verification needs the exact raw bytes. Do not add a global JSON body parser in front of it.
26
+ - You own the dependency pins in `package.json` — update them as your app evolves.
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.log
5
+ .DS_Store
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "minimal",
3
+ "description": "Express-based Ratio app (default).",
4
+ "substitutionKeys": ["appName", "clientId", "sdkVersion", "templateName"]
5
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "{{appName}}",
3
+ "version": "0.1.0",
4
+ "description": "A Ratio app scaffolded from the minimal template.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "tsx watch src/index.ts",
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@ratio-app/sdk": "{{sdkVersion}}",
14
+ "express": "^4.19.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/express": "^4.17.21",
18
+ "@types/node": "^20.0.0",
19
+ "tsx": "^4.7.0",
20
+ "typescript": "^5.6.0"
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ }
25
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ // Canonical Ratio app manifest. Committed to the app repo.
3
+ // Written by scaffolder; `clientId` filled by `ratio app link`.
4
+ "$schema": "https://ratio.app/schema/app-manifest.v1.json",
5
+ "appName": "{{appName}}",
6
+ "clientId": "{{clientId}}",
7
+ "sdkVersion": "{{sdkVersion}}",
8
+ "template": "{{templateName}}",
9
+ "webhooks": {
10
+ "path": "/webhooks",
11
+ "topics": []
12
+ },
13
+ "oauth": {
14
+ "redirectPath": "/oauth/callback",
15
+ "scopes": []
16
+ }
17
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * {{appName}} — server entry point.
3
+ *
4
+ * Mounts the OAuth install callback and the verified webhook receiver.
5
+ */
6
+ import express from 'express';
7
+
8
+ import { installRouter } from './install.js';
9
+ import { webhooksRouter } from './webhooks.js';
10
+
11
+ const app = express();
12
+
13
+ // Order matters: the webhook route registers its own raw-body parser;
14
+ // keep any global JSON parsing away from it.
15
+ app.use(installRouter);
16
+ app.use(webhooksRouter);
17
+
18
+ app.get('/', (_req, res) => {
19
+ res.json({ ok: true, app: '{{appName}}', template: '{{templateName}}' });
20
+ });
21
+
22
+ const port = Number(process.env['PORT'] ?? 3000);
23
+
24
+ app.listen(port, () => {
25
+ console.log(JSON.stringify({ msg: 'server started', app: '{{appName}}', port }));
26
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * OAuth install callback.
3
+ *
4
+ * When a merchant approves the app, Ratio redirects the browser to
5
+ * `GET /oauth/callback?code=...`. This handler exchanges the code for
6
+ * tokens using the app's client secret.
7
+ */
8
+ import { Router } from 'express';
9
+ import { exchangeToken, type ExchangeTokenArgs } from '@ratio-app/sdk/oauth';
10
+
11
+ export const installRouter: Router = Router();
12
+
13
+ installRouter.get('/oauth/callback', async (req, res) => {
14
+ const code = req.query['code'];
15
+ if (typeof code !== 'string' || code === '') {
16
+ res.status(400).json({ ok: false, error: 'missing_code' });
17
+ return;
18
+ }
19
+
20
+ const args: ExchangeTokenArgs = {
21
+ code,
22
+ clientId: '{{clientId}}',
23
+ clientSecret: process.env['RATIO_CLIENT_SECRET'] ?? '',
24
+ };
25
+
26
+ try {
27
+ // exchangeToken returns `unknown` — narrow before reading fields
28
+ // (e.g. with a type guard or a schema parser).
29
+ const tokens: unknown = await exchangeToken(args);
30
+ void tokens; // TODO: persist tokens for this merchant.
31
+ res.status(200).json({ ok: true });
32
+ } catch {
33
+ // Never log the raw error object here — it can carry secret material.
34
+ console.error(JSON.stringify({ msg: 'token exchange failed' }));
35
+ res.status(502).json({ ok: false, error: 'token_exchange_failed' });
36
+ }
37
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Webhook receiver.
3
+ *
4
+ * Verifies the HMAC signature over the raw request bytes, then
5
+ * dispatches the event by topic.
6
+ */
7
+ import express, { Router } from 'express';
8
+ import { verify, type VerifyInput } from '@ratio-app/sdk/webhooks';
9
+
10
+ export const webhooksRouter: Router = Router();
11
+
12
+ webhooksRouter.post(
13
+ '/webhooks',
14
+ // Raw body on purpose: the SDK requires the exact raw body bytes for
15
+ // HMAC verification. Parsing JSON first changes the byte sequence and
16
+ // the signature will not match.
17
+ express.raw({ type: 'application/json' }),
18
+ (req, res) => {
19
+ const rawBody = req.body as Buffer;
20
+
21
+ const input: VerifyInput = {
22
+ rawBody,
23
+ signature: String(req.headers['x-ratio-signature'] ?? ''),
24
+ secret: process.env['RATIO_WEBHOOK_SECRET'] ?? '',
25
+ };
26
+
27
+ try {
28
+ verify(input);
29
+ } catch (err) {
30
+ // The SDK error carries a stable `code`. Do not log the raw body
31
+ // or the full error object.
32
+ const code = (err as { code?: string }).code ?? 'unknown';
33
+ console.error(JSON.stringify({ msg: 'webhook verification failed', code }));
34
+ res.status(400).json({ ok: false, code });
35
+ return;
36
+ }
37
+
38
+ // Safe to parse now — the signature matched.
39
+ const event = JSON.parse(rawBody.toString('utf8')) as { type?: string };
40
+
41
+ switch (event.type) {
42
+ case 'orders.created':
43
+ // TODO: handle new orders.
44
+ break;
45
+ case 'products.updated':
46
+ // TODO: handle product updates.
47
+ break;
48
+ default:
49
+ console.log(
50
+ JSON.stringify({ msg: 'unhandled webhook topic', topic: event.type ?? 'unknown' }),
51
+ );
52
+ break;
53
+ }
54
+
55
+ res.status(200).json({ ok: true });
56
+ },
57
+ );
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "outDir": "./dist",
13
+ "rootDir": "./src"
14
+ },
15
+ "include": ["src/**/*.ts"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
@@ -0,0 +1,28 @@
1
+ # {{appName}}
2
+
3
+ Scaffolded from the Ratio `{{templateName}}` template — a Cloudflare
4
+ Workers Ratio app with OAuth install and verified webhooks wired up via
5
+ `@ratio-app/sdk`.
6
+
7
+ ## Getting started
8
+
9
+ 1. Install dependencies: `npm install`.
10
+ 2. Create your Ratio app manually in the Ratio developer dashboard and copy the client id.
11
+ 3. Open `ratio.config.jsonc` and paste the client id into the `clientId` field (or run `ratio app link` to auto-fill it).
12
+ 4. Set your secrets with `wrangler secret put RATIO_CLIENT_SECRET` and `wrangler secret put RATIO_WEBHOOK_SECRET` (values from the dashboard).
13
+ 5. Run `npm run dev` to start `wrangler dev` locally, then `npm run deploy` when ready.
14
+ 6. Point your merchant test store at your `*.workers.dev` route: `https://<worker>.<subdomain>.workers.dev/oauth/callback` and `https://<worker>.<subdomain>.workers.dev/webhooks`.
15
+
16
+ ## Project layout
17
+
18
+ - `src/index.ts` — Workers `fetch` handler; routes the install and webhook paths.
19
+ - `src/install.ts` — OAuth install callback (`GET /oauth/callback`); exchanges the authorization code for tokens.
20
+ - `src/webhooks.ts` — webhook receiver (`POST /webhooks`); verifies the HMAC signature over the raw body, then dispatches by topic.
21
+ - `ratio.config.jsonc` — the app manifest committed to this repo.
22
+ - `wrangler.toml` — Workers config; keep `compatibility_flags = ["nodejs_compat"]` (the SDK uses `node:crypto` at runtime).
23
+
24
+ ## Notes
25
+
26
+ - The webhook handler reads the raw request body with `request.text()` before verification on purpose: signature verification needs the exact payload bytes. Parse JSON only after `verify()` succeeds.
27
+ - Secrets live in Workers secrets (`wrangler secret put ...`), never in committed files.
28
+ - You own the dependency pins in `package.json` — update them as your app evolves.
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .wrangler/
3
+ dist/
4
+ .env
5
+ *.log
6
+ .DS_Store
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "serverless",
3
+ "description": "Cloudflare Workers Ratio app.",
4
+ "substitutionKeys": ["appName", "clientId", "sdkVersion", "templateName"]
5
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "{{appName}}",
3
+ "version": "0.1.0",
4
+ "description": "A Ratio app scaffolded from the serverless (Cloudflare Workers) template.",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "wrangler dev",
8
+ "deploy": "wrangler deploy",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@ratio-app/sdk": "{{sdkVersion}}"
13
+ },
14
+ "devDependencies": {
15
+ "@cloudflare/workers-types": "^4.20240909.0",
16
+ "typescript": "^5.6.0",
17
+ "wrangler": "^3.80.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ }
22
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ // Canonical Ratio app manifest. Committed to the app repo.
3
+ // Written by scaffolder; `clientId` filled by `ratio app link`.
4
+ "$schema": "https://ratio.app/schema/app-manifest.v1.json",
5
+ "appName": "{{appName}}",
6
+ "clientId": "{{clientId}}",
7
+ "sdkVersion": "{{sdkVersion}}",
8
+ "template": "{{templateName}}",
9
+ "webhooks": {
10
+ "path": "/webhooks",
11
+ "topics": []
12
+ },
13
+ "oauth": {
14
+ "redirectPath": "/oauth/callback",
15
+ "scopes": []
16
+ }
17
+ }
@@ -0,0 +1,20 @@
1
+ export interface Env {
2
+ RATIO_CLIENT_SECRET: string;
3
+ RATIO_WEBHOOK_SECRET: string;
4
+ }
5
+
6
+ import { handleInstall } from './install.js';
7
+ import { handleWebhook } from './webhooks.js';
8
+
9
+ export default {
10
+ async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
11
+ const url = new URL(request.url);
12
+ if (url.pathname === '/oauth/callback') {
13
+ return handleInstall(request, env);
14
+ }
15
+ if (url.pathname === '/webhooks') {
16
+ return handleWebhook(request, env);
17
+ }
18
+ return new Response('Not found', { status: 404 });
19
+ },
20
+ } satisfies ExportedHandler<Env>;
@@ -0,0 +1,20 @@
1
+ import { exchangeToken } from '@ratio-app/sdk/oauth';
2
+ import type { Env } from './index.js';
3
+
4
+ export async function handleInstall(request: Request, env: Env): Promise<Response> {
5
+ const url = new URL(request.url);
6
+ const code = url.searchParams.get('code');
7
+ if (code === null) {
8
+ return new Response('Missing code', { status: 400 });
9
+ }
10
+ const result = await exchangeToken({
11
+ code,
12
+ clientId: '{{clientId}}',
13
+ clientSecret: env.RATIO_CLIENT_SECRET,
14
+ });
15
+ // TODO: persist tokens (KV / D1 / durable object).
16
+ return new Response(JSON.stringify({ ok: true, result }), {
17
+ status: 200,
18
+ headers: { 'content-type': 'application/json' },
19
+ });
20
+ }