@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,1187 @@
1
+ // src/commands/dev/listen.ts
2
+ import { Flags as Flags2 } from "@oclif/core";
3
+ import { z as z2 } from "zod";
4
+
5
+ // src/lib/auth/config.ts
6
+ var DEFAULT_SERVER_URL = "https://gkx.ratio.win";
7
+ function resolveServerUrl(opts) {
8
+ const env = opts?.env ?? process.env;
9
+ if (typeof env["RATIO_SERVER_URL"] === "string" && env["RATIO_SERVER_URL"].length > 0) {
10
+ return env["RATIO_SERVER_URL"];
11
+ }
12
+ if (typeof opts?.credentials?.server === "string" && opts.credentials.server.length > 0) {
13
+ return opts.credentials.server;
14
+ }
15
+ return DEFAULT_SERVER_URL;
16
+ }
17
+
18
+ // src/lib/credentials/clock.ts
19
+ var SystemClock = class {
20
+ now() {
21
+ return Date.now();
22
+ }
23
+ };
24
+
25
+ // src/lib/credentials/path.ts
26
+ import os from "os";
27
+ import path from "path";
28
+ function snapshotPathEnv() {
29
+ const snapshot = {};
30
+ const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
31
+ if (xdgConfigHome !== void 0) {
32
+ snapshot.XDG_CONFIG_HOME = xdgConfigHome;
33
+ }
34
+ const appData = process.env["APPDATA"];
35
+ if (appData !== void 0) {
36
+ snapshot.APPDATA = appData;
37
+ }
38
+ return snapshot;
39
+ }
40
+ function defaultCredentialsPath(platform, homedir, env) {
41
+ const resolvedPlatform = platform ?? process.platform;
42
+ const resolvedHome = homedir ?? os.homedir();
43
+ const resolvedEnv = env ?? snapshotPathEnv();
44
+ if (resolvedPlatform === "win32") {
45
+ const appData = resolvedEnv.APPDATA;
46
+ const base2 = appData !== void 0 && appData !== "" ? appData : path.win32.join(resolvedHome, "AppData", "Roaming");
47
+ return path.win32.join(base2, "ratio", "credentials");
48
+ }
49
+ const xdgConfigHome = resolvedEnv.XDG_CONFIG_HOME;
50
+ const base = typeof xdgConfigHome === "string" && xdgConfigHome !== "" ? xdgConfigHome : path.posix.join(resolvedHome, ".config");
51
+ return path.posix.join(base, "ratio", "credentials");
52
+ }
53
+
54
+ // src/lib/errors.ts
55
+ var RatioCLIError = class extends Error {
56
+ code;
57
+ exitCode;
58
+ hint;
59
+ docsUrl;
60
+ constructor(opts) {
61
+ super(opts.message);
62
+ this.name = new.target.name;
63
+ this.code = opts.code;
64
+ this.exitCode = opts.exitCode;
65
+ if (opts.hint !== void 0) this.hint = opts.hint;
66
+ if (opts.docsUrl !== void 0) this.docsUrl = opts.docsUrl;
67
+ }
68
+ };
69
+ var NotLoggedInError = class extends RatioCLIError {
70
+ constructor(message, hint = "Run 'ratio auth login' to sign in.") {
71
+ super({ code: "NOT_LOGGED_IN", exitCode: 3, message, hint });
72
+ }
73
+ };
74
+ var TokenExpiredError = class extends RatioCLIError {
75
+ constructor(message, hint = "Run 'ratio auth login' to refresh your session.") {
76
+ super({ code: "TOKEN_EXPIRED", exitCode: 3, message, hint });
77
+ }
78
+ };
79
+ var RelayConnectError = class extends RatioCLIError {
80
+ constructor(message, hint = "Verify the tunnel URL and that the auth-server is reachable.") {
81
+ super({ code: "RELAY_CONNECT", exitCode: 6, message, hint });
82
+ }
83
+ };
84
+ var ValidationError = class extends RatioCLIError {
85
+ constructor(message, hint) {
86
+ const opts = {
87
+ code: "VALIDATION_ERROR",
88
+ exitCode: 8,
89
+ message
90
+ };
91
+ if (hint !== void 0) opts.hint = hint;
92
+ super(opts);
93
+ }
94
+ };
95
+ var RelayResumeGapError = class extends RatioCLIError {
96
+ constructor(message, hint = "Reconnect from the current cursor; older events are outside the buffer window.") {
97
+ super({ code: "RELAY_RESUME_GAP", exitCode: 9, message, hint });
98
+ }
99
+ };
100
+ function isRatioCLIError(err) {
101
+ return err instanceof RatioCLIError;
102
+ }
103
+
104
+ // src/lib/credentials/refresher.ts
105
+ var NotImplementedRefresher = class {
106
+ async refresh(refreshToken) {
107
+ void refreshToken;
108
+ throw new NotLoggedInError(
109
+ "Token refresh is not available yet in this CLI version."
110
+ );
111
+ }
112
+ };
113
+
114
+ // src/lib/credentials/store.ts
115
+ import crypto from "crypto";
116
+ import fs from "fs";
117
+ import { dirname } from "path";
118
+
119
+ // src/lib/credentials/schema.ts
120
+ import { z } from "zod";
121
+ var identitySchema = z.object({
122
+ developerId: z.string(),
123
+ email: z.string(),
124
+ // Token-derived fields captured at login. Optional (additive) so files
125
+ // written before these fields existed still validate as version 1.
126
+ merchantId: z.string().optional(),
127
+ scopes: z.array(z.string()).optional()
128
+ }).strict();
129
+ var profileSchema = z.object({
130
+ accessToken: z.string(),
131
+ refreshToken: z.string(),
132
+ expiresAt: z.string().datetime({ offset: true }),
133
+ identity: identitySchema,
134
+ obtainedAt: z.string().datetime({ offset: true })
135
+ }).strict();
136
+ var developerSessionSchema = z.object({
137
+ jwt: z.string(),
138
+ developerId: z.string(),
139
+ email: z.string(),
140
+ expiresAt: z.string().datetime({ offset: true }),
141
+ obtainedAt: z.string().datetime({ offset: true })
142
+ }).strict();
143
+ var credentialsSchema = z.object({
144
+ version: z.literal(1),
145
+ server: z.string(),
146
+ clientId: z.string(),
147
+ profiles: z.record(z.string(), profileSchema),
148
+ // Optional cached developer session. Additive so files written before
149
+ // this field existed still validate as version 1.
150
+ developer: developerSessionSchema.optional()
151
+ }).strict();
152
+ function parseCredentials(input) {
153
+ if (typeof input === "object" && input !== null && "version" in input) {
154
+ const version = input.version;
155
+ if (version !== 1) {
156
+ const label = typeof version === "number" ? String(version) : "unknown";
157
+ throw new ValidationError(
158
+ `credentials file version ${label} is unsupported by this CLI \u2014 upgrade the CLI or sign in again`
159
+ );
160
+ }
161
+ }
162
+ const result = credentialsSchema.safeParse(input);
163
+ if (!result.success) {
164
+ throw new ValidationError(
165
+ "credentials file schema mismatch \u2014 expected v1 shape"
166
+ );
167
+ }
168
+ return result.data;
169
+ }
170
+
171
+ // src/lib/credentials/store.ts
172
+ var REFRESH_WINDOW_MS = 3e5;
173
+ var ENV_TOKEN_VAR = "RATIO_API_TOKEN";
174
+ var ENV_DEVELOPER_TOKEN_VAR = "RATIO_DEVELOPER_TOKEN";
175
+ function hasErrnoCode(err, code) {
176
+ return err instanceof Error && err.code === code;
177
+ }
178
+ var FileCredentialStore = class {
179
+ filePath;
180
+ clock;
181
+ refresher;
182
+ envReader;
183
+ platform;
184
+ constructor(opts) {
185
+ this.filePath = opts?.filePath ?? defaultCredentialsPath();
186
+ this.clock = opts?.clock ?? new SystemClock();
187
+ this.refresher = opts?.refresher ?? new NotImplementedRefresher();
188
+ this.envReader = opts?.envReader ?? (() => process.env);
189
+ this.platform = opts?.platform ?? process.platform;
190
+ }
191
+ async load() {
192
+ this.reapplyFileModeIfDrifted();
193
+ let raw;
194
+ try {
195
+ raw = fs.readFileSync(this.filePath, "utf8");
196
+ } catch (err) {
197
+ if (hasErrnoCode(err, "ENOENT")) {
198
+ throw new NotLoggedInError("You are not signed in.");
199
+ }
200
+ throw err;
201
+ }
202
+ let parsed;
203
+ try {
204
+ parsed = JSON.parse(raw);
205
+ } catch {
206
+ throw new ValidationError(
207
+ 'credentials file is not valid JSON \u2014 run "ratio auth login" to reset'
208
+ );
209
+ }
210
+ return parseCredentials(parsed);
211
+ }
212
+ async save(creds) {
213
+ const dir = dirname(this.filePath);
214
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
215
+ if (this.platform !== "win32") {
216
+ fs.chmodSync(dir, 448);
217
+ }
218
+ const tmpPath = `${this.filePath}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
219
+ try {
220
+ fs.writeFileSync(tmpPath, JSON.stringify(creds, null, 2), {
221
+ mode: 384
222
+ });
223
+ fs.renameSync(tmpPath, this.filePath);
224
+ } catch (err) {
225
+ try {
226
+ fs.unlinkSync(tmpPath);
227
+ } catch {
228
+ }
229
+ throw err;
230
+ }
231
+ if (this.platform !== "win32") {
232
+ fs.chmodSync(this.filePath, 384);
233
+ }
234
+ }
235
+ async clear() {
236
+ try {
237
+ fs.unlinkSync(this.filePath);
238
+ } catch (err) {
239
+ if (hasErrnoCode(err, "ENOENT")) {
240
+ return;
241
+ }
242
+ throw err;
243
+ }
244
+ }
245
+ async getValidAccessToken(profileName) {
246
+ const envToken = this.envReader()[ENV_TOKEN_VAR];
247
+ if (typeof envToken === "string" && envToken.length > 0) {
248
+ return envToken;
249
+ }
250
+ const creds = await this.load();
251
+ const name = profileName ?? "default";
252
+ const profile = creds.profiles[name];
253
+ if (profile === void 0) {
254
+ throw new NotLoggedInError("You are not signed in.");
255
+ }
256
+ const msUntilExpiry = new Date(profile.expiresAt).getTime() - this.clock.now();
257
+ if (msUntilExpiry < REFRESH_WINDOW_MS) {
258
+ return this.refreshAndPersist(creds, name, profile);
259
+ }
260
+ return profile.accessToken;
261
+ }
262
+ async getValidDeveloperToken() {
263
+ const envToken = this.envReader()[ENV_DEVELOPER_TOKEN_VAR];
264
+ if (typeof envToken === "string" && envToken.length > 0) {
265
+ return envToken;
266
+ }
267
+ let creds;
268
+ try {
269
+ creds = await this.load();
270
+ } catch (err) {
271
+ if (err instanceof NotLoggedInError) {
272
+ throw new NotLoggedInError("Developer signin required.");
273
+ }
274
+ throw err;
275
+ }
276
+ const session = creds.developer;
277
+ if (session === void 0) {
278
+ throw new NotLoggedInError("Developer signin required.");
279
+ }
280
+ const msUntilExpiry = new Date(session.expiresAt).getTime() - this.clock.now();
281
+ if (msUntilExpiry < REFRESH_WINDOW_MS) {
282
+ throw new NotLoggedInError("Developer signin required.");
283
+ }
284
+ return session.jwt;
285
+ }
286
+ async saveDeveloperSession(session) {
287
+ let creds;
288
+ try {
289
+ creds = await this.load();
290
+ } catch (err) {
291
+ if (err instanceof NotLoggedInError) {
292
+ creds = { version: 1, server: "", clientId: "", profiles: {} };
293
+ } else {
294
+ throw err;
295
+ }
296
+ }
297
+ const updated = {
298
+ ...creds,
299
+ developer: session
300
+ };
301
+ await this.save(updated);
302
+ }
303
+ /**
304
+ * Exchange the profile's refresh token for a fresh token set, merge it
305
+ * into the loaded credentials (preserving every other profile), persist
306
+ * atomically, and return the new access token. A refresher failure is
307
+ * wrapped in TokenExpiredError; the underlying message is deliberately
308
+ * discarded so a secret can never leak through the error surface.
309
+ */
310
+ async refreshAndPersist(creds, name, profile) {
311
+ let refreshed;
312
+ try {
313
+ refreshed = await this.refresher.refresh(profile.refreshToken);
314
+ } catch {
315
+ throw new TokenExpiredError(
316
+ "Your session has expired and could not be refreshed."
317
+ );
318
+ }
319
+ const updated = {
320
+ ...creds,
321
+ profiles: {
322
+ ...creds.profiles,
323
+ [name]: {
324
+ ...profile,
325
+ accessToken: refreshed.accessToken,
326
+ refreshToken: refreshed.refreshToken,
327
+ expiresAt: refreshed.expiresAt
328
+ }
329
+ }
330
+ };
331
+ await this.save(updated);
332
+ return refreshed.accessToken;
333
+ }
334
+ /**
335
+ * POSIX-only startup permission check: if the file exists with a mode
336
+ * other than 0600, re-apply 0600 and emit a warning to stderr. Never
337
+ * runs on Windows (stat modes there do not map to POSIX bits and chmod
338
+ * must not be called). Missing file is NOT an error here — load()
339
+ * reports it as NotLoggedInError.
340
+ */
341
+ reapplyFileModeIfDrifted() {
342
+ if (this.platform === "win32") {
343
+ return;
344
+ }
345
+ let stat;
346
+ try {
347
+ stat = fs.statSync(this.filePath);
348
+ } catch (err) {
349
+ if (hasErrnoCode(err, "ENOENT")) {
350
+ return;
351
+ }
352
+ throw err;
353
+ }
354
+ const mode = stat.mode & 511;
355
+ if (mode !== 384) {
356
+ fs.chmodSync(this.filePath, 384);
357
+ process.stderr.write(
358
+ `[warn] credentials file permissions were 0o${mode.toString(8)}; corrected to 0600.
359
+ `
360
+ );
361
+ }
362
+ }
363
+ };
364
+
365
+ // src/lib/ratio-command.ts
366
+ import { Command, Errors } from "@oclif/core";
367
+
368
+ // src/lib/render-error.ts
369
+ function isOclifParserError(err) {
370
+ return err instanceof Error && "parse" in err && err.parse !== null && err.parse !== void 0;
371
+ }
372
+ function buildErrorEnvelope(code, message, hint, docsUrl) {
373
+ const obj = { ok: false, code, message };
374
+ if (hint !== void 0) obj["hint"] = hint;
375
+ if (docsUrl !== void 0) obj["docsUrl"] = docsUrl;
376
+ return JSON.stringify(obj) + "\n";
377
+ }
378
+ function renderError(input) {
379
+ const { err, verbose, json } = input;
380
+ if (isRatioCLIError(err)) {
381
+ const ratioErr = err;
382
+ if (ratioErr.exitCode === 0) {
383
+ if (json) {
384
+ return {
385
+ stdout: JSON.stringify({ ok: true, data: null }) + "\n",
386
+ stderr: "",
387
+ exitCode: 0
388
+ };
389
+ }
390
+ return {
391
+ stdout: ratioErr.message + "\n",
392
+ stderr: "",
393
+ exitCode: 0
394
+ };
395
+ }
396
+ let stderrLines = `${ratioErr.code}: ${ratioErr.message}
397
+ `;
398
+ if (ratioErr.hint !== void 0) stderrLines += `${ratioErr.hint}
399
+ `;
400
+ if (ratioErr.docsUrl !== void 0) stderrLines += `${ratioErr.docsUrl}
401
+ `;
402
+ if (json) {
403
+ return {
404
+ stdout: buildErrorEnvelope(ratioErr.code, ratioErr.message, ratioErr.hint, ratioErr.docsUrl),
405
+ stderr: verbose && ratioErr.stack ? ratioErr.stack + "\n" : "",
406
+ exitCode: ratioErr.exitCode
407
+ };
408
+ }
409
+ if (verbose && ratioErr.stack) {
410
+ stderrLines += ratioErr.stack + "\n";
411
+ }
412
+ return { stdout: "", stderr: stderrLines, exitCode: ratioErr.exitCode };
413
+ }
414
+ if (isOclifParserError(err)) {
415
+ const parserErr = err;
416
+ const msg = parserErr.message || "Unknown parse error";
417
+ const coercedErr = new ValidationError(msg);
418
+ let stderrText2 = `${coercedErr.code}: ${msg}
419
+ `;
420
+ if (json) {
421
+ return {
422
+ stdout: buildErrorEnvelope(coercedErr.code, msg),
423
+ stderr: verbose && parserErr.stack ? parserErr.stack + "\n" : "",
424
+ exitCode: 8
425
+ };
426
+ }
427
+ if (verbose && parserErr.stack) {
428
+ stderrText2 += parserErr.stack + "\n";
429
+ }
430
+ return { stdout: "", stderr: stderrText2, exitCode: 8 };
431
+ }
432
+ const message = err instanceof Error ? err.message : String(err);
433
+ const stack = err instanceof Error ? err.stack : void 0;
434
+ const stderrPrefix = `INTERNAL: ${message}
435
+ `;
436
+ if (json) {
437
+ return {
438
+ stdout: buildErrorEnvelope("INTERNAL", message),
439
+ stderr: verbose && stack ? stack + "\n" : "",
440
+ exitCode: 1
441
+ };
442
+ }
443
+ let stderrText = stderrPrefix;
444
+ if (verbose && stack) {
445
+ stderrText += stack + "\n";
446
+ }
447
+ return { stdout: "", stderr: stderrText, exitCode: 1 };
448
+ }
449
+
450
+ // src/lib/ratio-command.ts
451
+ var RatioCommand = class extends Command {
452
+ _getRenderFlags() {
453
+ const argv = this.argv;
454
+ return {
455
+ verbose: argv.includes("--verbose"),
456
+ json: argv.includes("--json")
457
+ };
458
+ }
459
+ async catch(err) {
460
+ const { verbose, json } = this._getRenderFlags();
461
+ const output = renderError({ err, verbose, json });
462
+ if (output.stdout) process.stdout.write(output.stdout);
463
+ if (output.stderr) process.stderr.write(output.stderr);
464
+ if (output.exitCode === 0) {
465
+ throw new Errors.ExitError(0);
466
+ }
467
+ err.skipOclifErrorHandling = true;
468
+ err.oclif = { exit: output.exitCode };
469
+ throw err;
470
+ }
471
+ };
472
+
473
+ // src/lib/relay/relay-client.ts
474
+ import WebSocket from "ws";
475
+ var LOCAL_BUFFER_MAX = 1e3;
476
+ var BACKOFF_BASE_MS = 100;
477
+ var BACKOFF_CAP_MS = 3e4;
478
+ var CLOSE_CODE_AUTH_REJECTED = 4401;
479
+ var CLOSE_CODE_NORMAL = 1e3;
480
+ var defaultScheduler = (fn, ms) => {
481
+ const handle = setTimeout(fn, ms);
482
+ return { cancel: () => clearTimeout(handle) };
483
+ };
484
+ var RelayClient = class {
485
+ wsUrl;
486
+ options;
487
+ scheduler;
488
+ random;
489
+ trace;
490
+ ws = null;
491
+ lastSeq = -1;
492
+ reconnectAttempt = 0;
493
+ reconnectTimer = null;
494
+ queue = [];
495
+ waiters = [];
496
+ /** First fatal error; once set, every consumer pull rejects with it. */
497
+ failure = null;
498
+ /** Clean end of stream (normal close or caller-initiated close()). */
499
+ ended = false;
500
+ userClosed = false;
501
+ started = false;
502
+ pendingConnect = null;
503
+ constructor(options) {
504
+ if (options.wsUrl === "") {
505
+ throw new ValidationError("Relay stream URL must not be empty.");
506
+ }
507
+ if (options.sessionId === "") {
508
+ throw new ValidationError("Relay session id must not be empty.");
509
+ }
510
+ let parsed;
511
+ try {
512
+ parsed = new URL(options.wsUrl);
513
+ } catch {
514
+ throw new ValidationError(
515
+ "Relay stream URL is not a valid URL.",
516
+ "The server returned a malformed stream endpoint \u2014 retry creating the session."
517
+ );
518
+ }
519
+ for (const [name, value] of parsed.searchParams.entries()) {
520
+ if (name === "sessionId") {
521
+ if (value !== options.sessionId) {
522
+ throw new ValidationError(
523
+ "Relay stream URL carries a session id that does not match the created session.",
524
+ "The server and client disagree on the session \u2014 retry creating the session."
525
+ );
526
+ }
527
+ continue;
528
+ }
529
+ throw new ValidationError(
530
+ "Relay stream URL must not carry query parameters other than the session id.",
531
+ "The client builds the connection query string itself."
532
+ );
533
+ }
534
+ this.options = options;
535
+ this.wsUrl = `${parsed.origin}${parsed.pathname}`;
536
+ this.scheduler = options.scheduler ?? defaultScheduler;
537
+ this.random = options.random ?? Math.random;
538
+ this.trace = options.tracer ?? (() => {
539
+ });
540
+ }
541
+ /**
542
+ * Opens the first connection. Resolves when the socket is open;
543
+ * rejects with a typed error on authentication rejection or when the
544
+ * stream fails before ever becoming established. May only be called
545
+ * once per client instance.
546
+ */
547
+ async connect() {
548
+ if (this.started) {
549
+ throw new ValidationError("connect() may only be called once.");
550
+ }
551
+ this.started = true;
552
+ await new Promise((resolve, reject) => {
553
+ this.pendingConnect = { resolve, reject };
554
+ void this.openSocket();
555
+ });
556
+ }
557
+ /**
558
+ * Pull-based event stream. Single-consumer: the iterator shares the
559
+ * client's internal queue. Ends when close() is called or the server
560
+ * closes cleanly; rejects with a typed error on fatal conditions.
561
+ */
562
+ events() {
563
+ return {
564
+ [Symbol.asyncIterator]: () => ({
565
+ next: () => this.nextEvent(),
566
+ return: async () => {
567
+ await this.close();
568
+ return { done: true, value: void 0 };
569
+ }
570
+ })
571
+ };
572
+ }
573
+ /**
574
+ * Graceful shutdown: cancels any pending reconnect, closes the
575
+ * socket with a normal-closure code, and completes the iterator.
576
+ */
577
+ async close() {
578
+ this.userClosed = true;
579
+ if (this.reconnectTimer !== null) {
580
+ this.reconnectTimer.cancel();
581
+ this.reconnectTimer = null;
582
+ }
583
+ const ws = this.ws;
584
+ if (ws === null || ws.readyState === WebSocket.CLOSED) {
585
+ this.endClean();
586
+ return;
587
+ }
588
+ await new Promise((resolve) => {
589
+ ws.once("close", () => resolve());
590
+ ws.close(CLOSE_CODE_NORMAL);
591
+ });
592
+ }
593
+ // ── internal state machine ────────────────────────────────────────
594
+ async openSocket() {
595
+ let token;
596
+ try {
597
+ token = await this.options.credentialStore.getValidAccessToken(
598
+ this.options.profileName
599
+ );
600
+ } catch (err) {
601
+ this.fail(err instanceof Error ? err : new RelayConnectError(
602
+ "Could not obtain an access token for the relay connection."
603
+ ));
604
+ return;
605
+ }
606
+ const query = new URLSearchParams({ sessionId: this.options.sessionId });
607
+ if (this.lastSeq !== -1) {
608
+ query.set("resumeCursor", String(this.lastSeq));
609
+ }
610
+ const url = `${this.wsUrl}?${query.toString()}`;
611
+ this.trace(
612
+ `relay connect: session=${this.options.sessionId} cursor=${this.lastSeq}`
613
+ );
614
+ const ws = new WebSocket(url, {
615
+ headers: { Authorization: `Bearer ${token}` }
616
+ });
617
+ this.ws = ws;
618
+ ws.on("open", () => {
619
+ this.reconnectAttempt = 0;
620
+ this.trace("relay connected");
621
+ if (this.pendingConnect !== null) {
622
+ this.pendingConnect.resolve();
623
+ this.pendingConnect = null;
624
+ }
625
+ });
626
+ ws.on("message", (data) => {
627
+ this.handleMessage(String(data));
628
+ });
629
+ ws.on("error", (err) => {
630
+ this.trace(`relay socket error: ${err.message}`);
631
+ });
632
+ ws.on("close", (code) => {
633
+ this.handleClose(code);
634
+ });
635
+ }
636
+ handleMessage(raw) {
637
+ let parsed;
638
+ try {
639
+ parsed = JSON.parse(raw);
640
+ } catch {
641
+ this.trace("relay frame dropped: not valid JSON");
642
+ return;
643
+ }
644
+ if (typeof parsed !== "object" || parsed === null) {
645
+ this.trace("relay frame dropped: not an object");
646
+ return;
647
+ }
648
+ const frame = parsed;
649
+ if (frame["kind"] === "control") {
650
+ if (frame["type"] === "resume-gap") {
651
+ this.trace("relay resume gap reported by server");
652
+ this.fail(
653
+ new RelayResumeGapError(
654
+ "The server could not replay all missed events: the resume cursor is older than the replay window."
655
+ )
656
+ );
657
+ return;
658
+ }
659
+ this.trace("relay control frame ignored: unknown type");
660
+ return;
661
+ }
662
+ if (frame["kind"] === "webhook" && typeof frame["seq"] === "number") {
663
+ const seq = frame["seq"];
664
+ if (seq <= this.lastSeq) {
665
+ return;
666
+ }
667
+ this.lastSeq = seq;
668
+ this.deliver({
669
+ kind: "webhook",
670
+ seq,
671
+ timestamp: typeof frame["timestamp"] === "number" ? new Date(frame["timestamp"]).toISOString() : "",
672
+ payload: frame["event"]
673
+ });
674
+ return;
675
+ }
676
+ this.trace("relay frame dropped: unrecognized envelope");
677
+ }
678
+ deliver(event) {
679
+ const waiter = this.waiters.shift();
680
+ if (waiter !== void 0) {
681
+ waiter.resolve({ done: false, value: event });
682
+ return;
683
+ }
684
+ if (this.queue.length >= LOCAL_BUFFER_MAX) {
685
+ this.trace("relay local buffer overflow \u2014 closing connection");
686
+ this.fail(
687
+ new RelayConnectError(
688
+ "Local buffer overflow \u2014 consumer stalled",
689
+ "Slow down the consumer or reduce upstream event rate."
690
+ )
691
+ );
692
+ return;
693
+ }
694
+ this.queue.push(event);
695
+ }
696
+ handleClose(code) {
697
+ this.ws = null;
698
+ if (this.failure !== null) {
699
+ return;
700
+ }
701
+ if (this.userClosed || code === CLOSE_CODE_NORMAL) {
702
+ this.trace(`relay closed cleanly (code ${code})`);
703
+ this.endClean();
704
+ return;
705
+ }
706
+ if (code === CLOSE_CODE_AUTH_REJECTED) {
707
+ this.trace("relay rejected the connection: authentication failed");
708
+ this.fail(
709
+ new RelayConnectError(
710
+ "The relay rejected the connection: authentication failed (close code 4401).",
711
+ "Run 'ratio auth login' and retry."
712
+ )
713
+ );
714
+ return;
715
+ }
716
+ this.scheduleReconnect(code);
717
+ }
718
+ scheduleReconnect(closeCode) {
719
+ this.reconnectAttempt += 1;
720
+ const base = Math.min(
721
+ 2 ** this.reconnectAttempt * BACKOFF_BASE_MS,
722
+ BACKOFF_CAP_MS
723
+ );
724
+ const delay = base * (0.75 + this.random() * 0.5);
725
+ this.trace(
726
+ `relay reconnect attempt ${this.reconnectAttempt} in ${Math.round(delay)}ms (close code ${closeCode})`
727
+ );
728
+ this.reconnectTimer = this.scheduler(() => {
729
+ this.reconnectTimer = null;
730
+ void this.openSocket();
731
+ }, delay);
732
+ }
733
+ fail(err) {
734
+ if (this.failure !== null) {
735
+ return;
736
+ }
737
+ this.failure = err;
738
+ if (this.reconnectTimer !== null) {
739
+ this.reconnectTimer.cancel();
740
+ this.reconnectTimer = null;
741
+ }
742
+ if (this.pendingConnect !== null) {
743
+ this.pendingConnect.reject(err);
744
+ this.pendingConnect = null;
745
+ }
746
+ let waiter = this.waiters.shift();
747
+ while (waiter !== void 0) {
748
+ waiter.reject(err);
749
+ waiter = this.waiters.shift();
750
+ }
751
+ const ws = this.ws;
752
+ if (ws !== null && ws.readyState !== WebSocket.CLOSED) {
753
+ ws.close(CLOSE_CODE_NORMAL);
754
+ }
755
+ }
756
+ endClean() {
757
+ if (this.ended) {
758
+ return;
759
+ }
760
+ this.ended = true;
761
+ if (this.pendingConnect !== null) {
762
+ this.pendingConnect.reject(
763
+ new RelayConnectError(
764
+ "The relay connection closed before it was established."
765
+ )
766
+ );
767
+ this.pendingConnect = null;
768
+ }
769
+ let waiter = this.waiters.shift();
770
+ while (waiter !== void 0) {
771
+ waiter.resolve({ done: true, value: void 0 });
772
+ waiter = this.waiters.shift();
773
+ }
774
+ }
775
+ nextEvent() {
776
+ if (this.failure !== null) {
777
+ return Promise.reject(this.failure);
778
+ }
779
+ const queued = this.queue.shift();
780
+ if (queued !== void 0) {
781
+ return Promise.resolve({ done: false, value: queued });
782
+ }
783
+ if (this.ended) {
784
+ return Promise.resolve({ done: true, value: void 0 });
785
+ }
786
+ return new Promise((resolve, reject) => {
787
+ this.waiters.push({ resolve, reject });
788
+ });
789
+ }
790
+ };
791
+
792
+ // src/lib/verbose-flag.ts
793
+ import { Flags } from "@oclif/core";
794
+ var verboseFlag = {
795
+ verbose: Flags.boolean({
796
+ description: "Show diagnostic detail: HTTP status/timing lines for auth requests (stderr) and stack traces for internal errors."
797
+ })
798
+ };
799
+ var jsonFlag = {
800
+ json: Flags.boolean({
801
+ description: "Emit machine-readable JSON on stdout."
802
+ })
803
+ };
804
+
805
+ // src/commands/dev/listen.ts
806
+ var TARGET_PROBE_TIMEOUT_MS = 2e3;
807
+ var FORWARD_TIMEOUT_MS = 1e4;
808
+ var FORWARD_MAX_INFLIGHT = 32;
809
+ var SHUTDOWN_DRAIN_MS = 5e3;
810
+ var SIGNAL_FORCE_EXIT_WINDOW_MS = 2e3;
811
+ var FORWARD_ERR_PREFIX = "err:";
812
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
813
+ "localhost",
814
+ "127.0.0.1",
815
+ "::1"
816
+ ]);
817
+ var FORWARD_TO_HINT = "Use a local address with an explicit port, e.g. --forward-to localhost:3000/webhooks.";
818
+ var devSessionSchema = z2.object({
819
+ sessionId: z2.string().min(1),
820
+ wsUrl: z2.string().min(1),
821
+ expiresAt: z2.string()
822
+ });
823
+ function parseForwardTarget(raw) {
824
+ const trimmed = raw.trim();
825
+ if (trimmed === "") {
826
+ throw new ValidationError("--forward-to must not be empty.", FORWARD_TO_HINT);
827
+ }
828
+ let candidate = trimmed;
829
+ const hasScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(candidate);
830
+ if (!hasScheme && candidate.startsWith("::1")) {
831
+ candidate = `[::1]${candidate.slice("::1".length)}`;
832
+ }
833
+ if (!hasScheme) {
834
+ candidate = `http://${candidate}`;
835
+ }
836
+ let url;
837
+ try {
838
+ url = new URL(candidate);
839
+ } catch {
840
+ throw new ValidationError(
841
+ `--forward-to is not a valid URL: ${raw}`,
842
+ FORWARD_TO_HINT
843
+ );
844
+ }
845
+ if (url.protocol === "https:") {
846
+ throw new ValidationError(
847
+ "--forward-to does not support https targets; local forwarding uses plain http.",
848
+ FORWARD_TO_HINT
849
+ );
850
+ }
851
+ if (url.protocol !== "http:") {
852
+ throw new ValidationError(
853
+ `--forward-to has an unsupported scheme: ${url.protocol}//`,
854
+ FORWARD_TO_HINT
855
+ );
856
+ }
857
+ const hostname = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
858
+ if (!LOOPBACK_HOSTS.has(hostname)) {
859
+ throw new ValidationError(
860
+ `--forward-to host must be local; got: ${hostname}`,
861
+ FORWARD_TO_HINT
862
+ );
863
+ }
864
+ if (url.port === "") {
865
+ throw new ValidationError(
866
+ "--forward-to must include an explicit port.",
867
+ FORWARD_TO_HINT
868
+ );
869
+ }
870
+ return url;
871
+ }
872
+ function parseEventsFilter(raw) {
873
+ if (raw === void 0) {
874
+ return void 0;
875
+ }
876
+ const topics = raw.split(",").map((token) => token.trim()).filter((token) => token !== "");
877
+ if (topics.length === 0) {
878
+ throw new ValidationError(
879
+ "--events was provided but contains no topics.",
880
+ "Pass a comma-separated list, e.g. --events=orders.created,orders.updated."
881
+ );
882
+ }
883
+ return new Set(topics);
884
+ }
885
+ function extractTopic(payload) {
886
+ if (typeof payload !== "object" || payload === null) {
887
+ return void 0;
888
+ }
889
+ const topic = payload.topic;
890
+ return typeof topic === "string" ? topic : void 0;
891
+ }
892
+ function formatEventLine(mode, seq, timestamp, topic, status) {
893
+ if (mode === "json") {
894
+ return JSON.stringify({
895
+ seq,
896
+ t: timestamp,
897
+ topic: topic ?? null,
898
+ "forward-status": status
899
+ }) + "\n";
900
+ }
901
+ return `seq=${seq} t=${timestamp} topic=${topic ?? "-"} forward-status=${status}
902
+ `;
903
+ }
904
+ function shortReason(cause, aborted) {
905
+ if (aborted) {
906
+ return "timeout";
907
+ }
908
+ if (cause instanceof Error) {
909
+ const code = cause.cause?.code;
910
+ if (typeof code === "string" && code.length > 0) {
911
+ return code.toLowerCase();
912
+ }
913
+ }
914
+ return "network";
915
+ }
916
+ async function createDevSession(serverUrl, token, appId) {
917
+ const base = serverUrl.replace(/\/+$/, "");
918
+ let response;
919
+ try {
920
+ response = await fetch(`${base}/dev/sessions`, {
921
+ method: "POST",
922
+ headers: {
923
+ Authorization: `Bearer ${token}`,
924
+ "content-type": "application/json"
925
+ },
926
+ body: JSON.stringify({ appId })
927
+ });
928
+ } catch {
929
+ throw new RelayConnectError("Could not reach the dev-session endpoint.");
930
+ }
931
+ if (response.status === 400) {
932
+ throw new ValidationError(
933
+ "The server rejected the dev-session request.",
934
+ "You may have too many active dev sessions; wait for one to expire and retry."
935
+ );
936
+ }
937
+ if (!response.ok) {
938
+ throw new RelayConnectError(
939
+ `Failed to create dev session (status: ${response.status})`
940
+ );
941
+ }
942
+ let body;
943
+ try {
944
+ body = await response.json();
945
+ } catch {
946
+ throw new RelayConnectError("Dev-session response was not the expected shape.");
947
+ }
948
+ const parsed = devSessionSchema.safeParse(body);
949
+ if (!parsed.success) {
950
+ throw new RelayConnectError("Dev-session response was not the expected shape.");
951
+ }
952
+ return parsed.data;
953
+ }
954
+ async function drainPending(pending, capMs) {
955
+ if (pending.size === 0) {
956
+ return;
957
+ }
958
+ let capTimer;
959
+ const cap = new Promise((resolve) => {
960
+ capTimer = setTimeout(resolve, capMs);
961
+ });
962
+ try {
963
+ await Promise.race([Promise.all([...pending]), cap]);
964
+ } finally {
965
+ if (capTimer !== void 0) {
966
+ clearTimeout(capTimer);
967
+ }
968
+ }
969
+ }
970
+ var DevListen = class _DevListen extends RatioCommand {
971
+ static description = "Stream webhook events from your dev session and forward them to a local HTTP server.";
972
+ static examples = [
973
+ "<%= config.bin %> dev listen --forward-to localhost:3000/webhooks --app-id=4f9d2c31-8a6b-4f2e-9c1d-3e5a7b9d0f12",
974
+ "<%= config.bin %> dev listen --forward-to http://127.0.0.1:3000/webhooks --events=orders.created,orders.updated --app-id=4f9d2c31-8a6b-4f2e-9c1d-3e5a7b9d0f12",
975
+ "<%= config.bin %> dev listen --forward-to localhost:3000/webhooks --json",
976
+ "<%= config.bin %> dev listen --forward-to localhost:3000/webhooks --app-id=4f9d2c31-8a6b-4f2e-9c1d-3e5a7b9d0f12 --verbose"
977
+ ];
978
+ static flags = {
979
+ ...verboseFlag,
980
+ ...jsonFlag,
981
+ "forward-to": Flags2.string({
982
+ description: "Local URL to forward events to (localhost only, explicit port required), e.g. localhost:3000/webhooks.",
983
+ required: true
984
+ }),
985
+ events: Flags2.string({
986
+ description: "Comma-separated event topics to forward (default: all)."
987
+ }),
988
+ "app-id": Flags2.string({
989
+ description: "App id whose dev session to open (UUID). Required.",
990
+ required: true
991
+ })
992
+ };
993
+ // ── Seams: overridable in tests; defaults hit the real process. ──────────
994
+ writeOut = (chunk) => {
995
+ process.stdout.write(chunk);
996
+ };
997
+ writeErr = (chunk) => {
998
+ process.stderr.write(chunk);
999
+ };
1000
+ signalSource = process;
1001
+ hardExit = (code) => {
1002
+ process.exit(code);
1003
+ };
1004
+ makeRelayClient = (options) => new RelayClient(options);
1005
+ async run() {
1006
+ const { flags } = await this.parse(_DevListen);
1007
+ const jsonMode = flags.json === true;
1008
+ const verbose = flags.verbose === true;
1009
+ const trace = (message) => {
1010
+ if (verbose) {
1011
+ this.writeErr(`${message}
1012
+ `);
1013
+ }
1014
+ };
1015
+ const target = parseForwardTarget(flags["forward-to"]);
1016
+ const targetUrl = target.toString();
1017
+ const topicFilter = parseEventsFilter(flags.events);
1018
+ const appId = flags["app-id"];
1019
+ if (appId === "") {
1020
+ throw new ValidationError(
1021
+ "A dev listen session requires an --app-id.",
1022
+ "Pass --app-id=<uuid>."
1023
+ );
1024
+ }
1025
+ const store = new FileCredentialStore();
1026
+ const token = await store.getValidAccessToken();
1027
+ const serverUrl = resolveServerUrl();
1028
+ const session = await createDevSession(serverUrl, token, appId);
1029
+ trace(`session created: ${session.sessionId} (expires ${session.expiresAt})`);
1030
+ await this.probeTarget(targetUrl, trace);
1031
+ const clientOptions = {
1032
+ sessionId: session.sessionId,
1033
+ wsUrl: session.wsUrl,
1034
+ credentialStore: store,
1035
+ ...verbose ? { tracer: (message) => this.writeErr(`${message}
1036
+ `) } : {}
1037
+ };
1038
+ const client = this.makeRelayClient(clientOptions);
1039
+ const emitEventLine = (seq, timestamp, topic, status) => {
1040
+ if (jsonMode) {
1041
+ this.writeOut(formatEventLine("json", seq, timestamp, topic, status));
1042
+ return;
1043
+ }
1044
+ this.writeErr(formatEventLine("kv", seq, timestamp, topic, status));
1045
+ };
1046
+ let inFlight = 0;
1047
+ const slotWaiters = [];
1048
+ const pending = /* @__PURE__ */ new Set();
1049
+ const releaseSlot = () => {
1050
+ const wake = slotWaiters.shift();
1051
+ if (wake !== void 0) {
1052
+ wake();
1053
+ }
1054
+ };
1055
+ let shuttingDown = false;
1056
+ let firstSignalAt = 0;
1057
+ const onSignal = () => {
1058
+ if (shuttingDown) {
1059
+ if (Date.now() - firstSignalAt <= SIGNAL_FORCE_EXIT_WINDOW_MS) {
1060
+ this.hardExit(130);
1061
+ }
1062
+ return;
1063
+ }
1064
+ shuttingDown = true;
1065
+ firstSignalAt = Date.now();
1066
+ this.writeErr(
1067
+ "shutting down: closing the event stream and draining in-flight forwards\n"
1068
+ );
1069
+ void client.close();
1070
+ };
1071
+ this.signalSource.on("SIGINT", onSignal);
1072
+ this.signalSource.on("SIGTERM", onSignal);
1073
+ try {
1074
+ await client.connect();
1075
+ this.writeErr(
1076
+ `listening: forwarding webhook events to ${targetUrl}
1077
+ `
1078
+ );
1079
+ try {
1080
+ for await (const event of client.events()) {
1081
+ if (event.kind !== "webhook") {
1082
+ emitEventLine(event.seq, event.timestamp, void 0, "dropped:control");
1083
+ continue;
1084
+ }
1085
+ const topic = extractTopic(event.payload);
1086
+ if (topicFilter !== void 0 && (topic === void 0 || !topicFilter.has(topic))) {
1087
+ emitEventLine(event.seq, event.timestamp, topic, "filtered");
1088
+ continue;
1089
+ }
1090
+ if (inFlight >= FORWARD_MAX_INFLIGHT) {
1091
+ trace("forward capacity reached; waiting for a slot");
1092
+ await new Promise((resolve) => {
1093
+ slotWaiters.push(resolve);
1094
+ });
1095
+ }
1096
+ inFlight += 1;
1097
+ const task = this.forwardOnce(targetUrl, event.payload).then((status) => {
1098
+ emitEventLine(event.seq, event.timestamp, topic, status);
1099
+ }).finally(() => {
1100
+ inFlight -= 1;
1101
+ pending.delete(task);
1102
+ releaseSlot();
1103
+ });
1104
+ pending.add(task);
1105
+ }
1106
+ } catch (streamErr) {
1107
+ if (streamErr instanceof RelayResumeGapError) {
1108
+ throw streamErr;
1109
+ }
1110
+ if (streamErr instanceof RelayConnectError && /4401|auth/i.test(streamErr.message)) {
1111
+ throw new RelayConnectError(
1112
+ streamErr.message,
1113
+ "Your session token was rejected. Run 'ratio auth login' to sign in again."
1114
+ );
1115
+ }
1116
+ throw streamErr;
1117
+ }
1118
+ await drainPending(pending, SHUTDOWN_DRAIN_MS);
1119
+ } finally {
1120
+ this.signalSource.removeListener("SIGINT", onSignal);
1121
+ this.signalSource.removeListener("SIGTERM", onSignal);
1122
+ await client.close().catch(() => void 0);
1123
+ }
1124
+ }
1125
+ /**
1126
+ * Best-effort reachability probe of the forward target. Never throws and
1127
+ * never blocks the session: a dead target only produces a warning, because
1128
+ * the user may start their local server after this command boots.
1129
+ */
1130
+ async probeTarget(targetUrl, trace) {
1131
+ const controller = new AbortController();
1132
+ const timer = setTimeout(() => controller.abort(), TARGET_PROBE_TIMEOUT_MS);
1133
+ try {
1134
+ const response = await fetch(targetUrl, {
1135
+ method: "HEAD",
1136
+ signal: controller.signal
1137
+ });
1138
+ trace(`target probe: responded with status ${response.status}`);
1139
+ } catch {
1140
+ this.writeErr(
1141
+ `warning: target ${targetUrl} is not responding yet; events will still be forwarded once your server is up
1142
+ `
1143
+ );
1144
+ } finally {
1145
+ clearTimeout(timer);
1146
+ }
1147
+ }
1148
+ /**
1149
+ * POST one event body to the local target. Resolves to a status token —
1150
+ * `ok:<status>` or `err:<reason>` — and NEVER rejects: a failed forward
1151
+ * is logged and dropped so the stream keeps flowing.
1152
+ */
1153
+ async forwardOnce(targetUrl, payload) {
1154
+ const controller = new AbortController();
1155
+ const timer = setTimeout(() => controller.abort(), FORWARD_TIMEOUT_MS);
1156
+ try {
1157
+ const response = await fetch(targetUrl, {
1158
+ method: "POST",
1159
+ headers: { "content-type": "application/json" },
1160
+ body: JSON.stringify(payload ?? null),
1161
+ signal: controller.signal
1162
+ });
1163
+ await response.arrayBuffer().catch(() => void 0);
1164
+ if (response.ok) {
1165
+ return `ok:${response.status}`;
1166
+ }
1167
+ return `${FORWARD_ERR_PREFIX}http-${response.status}`;
1168
+ } catch (cause) {
1169
+ return `${FORWARD_ERR_PREFIX}${shortReason(cause, controller.signal.aborted)}`;
1170
+ } finally {
1171
+ clearTimeout(timer);
1172
+ }
1173
+ }
1174
+ };
1175
+ export {
1176
+ FORWARD_MAX_INFLIGHT,
1177
+ FORWARD_TIMEOUT_MS,
1178
+ SHUTDOWN_DRAIN_MS,
1179
+ SIGNAL_FORCE_EXIT_WINDOW_MS,
1180
+ TARGET_PROBE_TIMEOUT_MS,
1181
+ DevListen as default,
1182
+ extractTopic,
1183
+ formatEventLine,
1184
+ parseEventsFilter,
1185
+ parseForwardTarget
1186
+ };
1187
+ //# sourceMappingURL=listen.js.map