@relayflows/sdk 2.0.18 → 2.0.19

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 (56) hide show
  1. package/dist/cli/build.d.ts +9 -0
  2. package/dist/cli/build.d.ts.map +1 -1
  3. package/dist/cli/build.js +25 -6
  4. package/dist/cli/build.js.map +1 -1
  5. package/dist/cli/cloud-run.d.ts +7 -1
  6. package/dist/cli/cloud-run.d.ts.map +1 -1
  7. package/dist/cli/cloud-run.js +12 -14
  8. package/dist/cli/cloud-run.js.map +1 -1
  9. package/dist/cli/cloud-sync.d.ts +8 -1
  10. package/dist/cli/cloud-sync.d.ts.map +1 -1
  11. package/dist/cli/cloud-sync.js +79 -8
  12. package/dist/cli/cloud-sync.js.map +1 -1
  13. package/dist/cli/deploy.d.ts +6 -0
  14. package/dist/cli/deploy.d.ts.map +1 -1
  15. package/dist/cli/deploy.js +29 -4
  16. package/dist/cli/deploy.js.map +1 -1
  17. package/dist/cli/serve-webhook.d.ts +7 -1
  18. package/dist/cli/serve-webhook.d.ts.map +1 -1
  19. package/dist/cli/serve-webhook.js +19 -9
  20. package/dist/cli/serve-webhook.js.map +1 -1
  21. package/dist/cli-commands.d.ts +398 -0
  22. package/dist/cli-commands.d.ts.map +1 -0
  23. package/dist/cli-commands.js +254 -0
  24. package/dist/cli-commands.js.map +1 -0
  25. package/dist/cli-watch.d.ts +3 -1
  26. package/dist/cli-watch.d.ts.map +1 -1
  27. package/dist/cli-watch.js +4 -10
  28. package/dist/cli-watch.js.map +1 -1
  29. package/dist/cli.d.ts +127 -1
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +81 -48
  32. package/dist/cli.js.map +1 -1
  33. package/dist/cloud-sync.d.ts +85 -2
  34. package/dist/cloud-sync.d.ts.map +1 -1
  35. package/dist/cloud-sync.js +123 -10
  36. package/dist/cloud-sync.js.map +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/relay-cli.d.ts +50 -0
  42. package/dist/relay-cli.d.ts.map +1 -0
  43. package/dist/relay-cli.js +64 -0
  44. package/dist/relay-cli.js.map +1 -0
  45. package/package.json +7 -2
  46. package/src/cli/build.ts +20 -6
  47. package/src/cli/cloud-run.ts +11 -12
  48. package/src/cli/cloud-sync.ts +85 -8
  49. package/src/cli/deploy.ts +27 -5
  50. package/src/cli/serve-webhook.ts +15 -8
  51. package/src/cli-commands.ts +339 -0
  52. package/src/cli-watch.ts +8 -9
  53. package/src/cli.ts +103 -44
  54. package/src/cloud-sync.ts +164 -11
  55. package/src/index.ts +4 -2
  56. package/src/relay-cli.ts +117 -0
@@ -4,6 +4,7 @@ import { createServer, type Server, type ServerResponse } from 'node:http';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { TextDecoder } from 'node:util';
6
6
  import type { CliIo } from '../cli.js';
7
+ import { DEFAULT_DATA_DIR } from '../daemon-connection.js';
7
8
  import { providerInboxEvent } from '../trigger-executor.js';
8
9
  import { verifySignature, schemeFor } from '../webhook-signature.js';
9
10
  import { TokenBucketLimiter, keyFor, type RateLimitConfig } from '../webhook-rate-limit.js';
@@ -24,9 +25,12 @@ export function parseWebhookArgs(args: readonly string[]): {
24
25
  || !value || value.startsWith('-')) return undefined;
25
26
  values.set(flag, value);
26
27
  }
27
- const dataDir = values.get('--data-dir');
28
+ // `--data-dir` is optional here as it is on every other verb, and defaults to
29
+ // the same directory: the command surface advertises that default, so a
30
+ // receiver started without the flag has to run rather than exit 2.
31
+ const dataDir = values.get('--data-dir') ?? DEFAULT_DATA_DIR;
28
32
  const portText = values.get('--port');
29
- if (!dataDir || !portText || !/^\d+$/.test(portText)) return undefined;
33
+ if (!portText || !/^\d+$/.test(portText)) return undefined;
30
34
  const port = Number(portText);
31
35
  if (!Number.isInteger(port) || port < 0 || port > 65535) return undefined;
32
36
  const allow = values.get('--allow');
@@ -215,6 +219,12 @@ async function directory(path: string): Promise<void> {
215
219
 
216
220
  export async function runServeWebhook(
217
221
  options: { dataDir: string; port: number; admitted?: readonly string[] }, io: CliIo,
222
+ /**
223
+ * Shutdown, owned by the caller. `runCli` supplies either the embedder's
224
+ * signal or one it drives from SIGINT/SIGTERM, so the receiver installs no
225
+ * process-wide handler of its own.
226
+ */
227
+ signal: AbortSignal,
218
228
  ): Promise<0 | 1> {
219
229
  try {
220
230
  const admittedNames = options.admitted === undefined ? undefined : new Set(options.admitted);
@@ -231,12 +241,9 @@ export async function runServeWebhook(
231
241
  server.closeAllConnections();
232
242
  };
233
243
  server.once('error', reject);
234
- process.once('SIGINT', stop);
235
- process.once('SIGTERM', stop);
236
- server.once('close', () => {
237
- process.off('SIGINT', stop);
238
- process.off('SIGTERM', stop);
239
- });
244
+ if (signal.aborted) { stop(); return; }
245
+ signal.addEventListener('abort', stop, { once: true });
246
+ server.once('close', () => signal.removeEventListener('abort', stop));
240
247
  });
241
248
  return 0;
242
249
  } catch (error) {
@@ -0,0 +1,339 @@
1
+ import { DEFAULT_DATA_DIR } from './daemon-connection.js';
2
+ import type { ParsedArgs } from './cli.js';
3
+
4
+ /**
5
+ * The one declaration of the `flows` command surface.
6
+ *
7
+ * Two consumers read this table and nothing else:
8
+ *
9
+ * 1. `parseArgs` in `cli.ts` gates its verb dispatch on {@link CLI_VERB_NAMES},
10
+ * so a token that is not in this table can never reach a parser.
11
+ * 2. `createRelayCliSurface` in `relay-cli.ts` projects it into the
12
+ * `RelayCliSurface.commands` tree the `agent-relay` host mounts.
13
+ *
14
+ * Because both sides derive from one array, `commands` and `run` cannot
15
+ * describe different trees. The drift test proves the remaining direction --
16
+ * that each declared command actually parses -- and {@link CLI_VERBS} carries a
17
+ * compile-time assertion that every `ParsedArgs` variant is claimed by some
18
+ * verb, so extending the union without extending this table fails `tsc`.
19
+ *
20
+ * The types here are deliberately local rather than imported from
21
+ * `@agent-relay/cli-surface`: they are structurally identical, and keeping the
22
+ * import out means `dist/relay-cli.d.ts` stands alone, so a consumer of
23
+ * `@relayflows/sdk` never needs the contract package resolvable. Assignability
24
+ * to the real contract is asserted in `tests/relay-cli-surface.test.ts`.
25
+ */
26
+
27
+ /** A positional argument. Mirrors `RelayCliArgSpec`. */
28
+ export interface CliArgSpec {
29
+ name: string;
30
+ description: string;
31
+ required: boolean;
32
+ variadic?: boolean;
33
+ }
34
+
35
+ /** A flag, in commander's flag-string form. Mirrors `RelayCliOptionSpec`. */
36
+ export interface CliOptionSpec {
37
+ flags: string;
38
+ description: string;
39
+ defaultValue?: string | boolean | number;
40
+ }
41
+
42
+ /** One node of the command tree. Mirrors `RelayCliCommandSpec`. */
43
+ export interface CliCommandSpec {
44
+ name: string;
45
+ description: string;
46
+ aliases?: readonly string[];
47
+ args?: readonly CliArgSpec[];
48
+ options?: readonly CliOptionSpec[];
49
+ subcommands?: readonly CliCommandSpec[];
50
+ }
51
+
52
+ /** A top-level verb, plus the `ParsedArgs` variants it can produce. */
53
+ export interface CliVerbSpec extends CliCommandSpec {
54
+ /**
55
+ * Every `ParsedArgs.command` this verb can parse to.
56
+ *
57
+ * Usually one, but the verb set and the variant set are not one-to-one:
58
+ * `deploy` produces `deploy` for a digest reference and `cloud-deploy` for
59
+ * authored source, and `run` produces `cloud-run` under `--cloud`.
60
+ */
61
+ variants: readonly ParsedArgs['command'][];
62
+ }
63
+
64
+ const DATA_DIR_OPTION: CliOptionSpec = {
65
+ flags: '--data-dir <dir>',
66
+ description: 'Daemon data directory',
67
+ defaultValue: DEFAULT_DATA_DIR,
68
+ };
69
+
70
+ const JSON_OPTION: CliOptionSpec = {
71
+ flags: '--json',
72
+ description: 'Emit one machine-readable JSON object instead of text',
73
+ };
74
+
75
+ /**
76
+ * Shared by the verbs that hand a flow to Cloud. Each of them preflights what
77
+ * the source needs connected and offers to connect it; this refuses instead,
78
+ * which is what a non-interactive caller wants.
79
+ */
80
+ const NO_CONNECT_OPTION: CliOptionSpec = {
81
+ flags: '--no-connect',
82
+ description: 'Refuse a missing integration instead of offering to connect it',
83
+ };
84
+
85
+ /** Flags shared by the two verbs that execute a flow locally. */
86
+ const LOCAL_EXECUTION_OPTIONS = [
87
+ JSON_OPTION,
88
+ DATA_DIR_OPTION,
89
+ { flags: '--local-agent', description: 'Run agent steps in this process instead of a worker' },
90
+ { flags: '--no-spawn', description: 'Require a running relayflowd rather than starting one' },
91
+ { flags: '--no-observer-link', description: 'Do not mint an observer link for this run' },
92
+ {
93
+ flags: '--allow-human-influenced',
94
+ description: 'Proceed even though the run carries human-influenced state',
95
+ },
96
+ ] as const satisfies readonly CliOptionSpec[];
97
+
98
+ /**
99
+ * `as const satisfies` rather than a type annotation on purpose: an annotation
100
+ * would widen every `variants` entry to `ParsedArgs['command']` and the
101
+ * compile-time exhaustiveness assertion below would pass vacuously.
102
+ */
103
+ export const CLI_VERBS = [
104
+ {
105
+ name: 'add',
106
+ description: 'Install a helper plugin into this project',
107
+ args: [{ name: 'helper', description: 'Helper name or @flows/<helper-name>', required: true }],
108
+ variants: ['add'],
109
+ },
110
+ {
111
+ name: 'answer',
112
+ description: 'Answer a run’s parked f.human question; `flows resume` then continues the body',
113
+ args: [
114
+ { name: 'run-id', description: 'Run parked on the question', required: true },
115
+ { name: 'wait-id', description: 'Which question to answer, named human-<n> in the order the body asked', required: true },
116
+ { name: 'answer', description: 'The decision, as yes or no (also true or false)', required: true },
117
+ ],
118
+ options: [
119
+ JSON_OPTION,
120
+ DATA_DIR_OPTION,
121
+ { flags: '--no-spawn', description: 'Require a running relayflowd rather than starting one' },
122
+ { flags: '--note <text>', description: 'Reason recorded on the journal alongside the answer' },
123
+ { flags: '--by <identity>', description: 'Who answered, when relaying a person’s decision; defaults to the OS user' },
124
+ ],
125
+ variants: ['answer'],
126
+ },
127
+ {
128
+ name: 'build',
129
+ description: 'Compile a flow into a sealed, content-addressed bundle',
130
+ args: [{ name: 'source', description: 'flow.yaml, flow.ts, or a bundle directory with --verify', required: true }],
131
+ options: [
132
+ { flags: '--out <dir>', description: 'Directory to write the bundle into; not valid with --verify' },
133
+ { flags: '--verify', description: 'Verify an existing bundle directory instead of building' },
134
+ JSON_OPTION,
135
+ ],
136
+ variants: ['build'],
137
+ },
138
+ {
139
+ name: 'check',
140
+ description: 'Compile and preflight a flow without running it, or opening a daemon socket',
141
+ args: [{ name: 'source', description: 'flow.ts, flow.yaml, or spec.json', required: true }],
142
+ options: [JSON_OPTION, { flags: '--watch', description: 'Re-check on every change to the flow and its imports' }],
143
+ variants: ['check'],
144
+ },
145
+ {
146
+ name: 'deploy',
147
+ description: 'Copy a sealed bundle into a file bucket, or deploy a hosted trigger listener',
148
+ args: [{ name: 'flow', description: 'flow.ts for a hosted listener, or <flow>@sha256:<digest> for a bundle', required: true }],
149
+ options: [
150
+ { flags: '--to <file-bucket-uri>', description: 'Destination file bucket for a sealed bundle' },
151
+ { flags: '--repo <owner/name>', description: 'Repository the hosted listener watches' },
152
+ { flags: '--on <provider>', description: 'Trigger source, as <provider>[:key=value,...]; repeatable' },
153
+ { flags: '--approver <handle>', description: 'Handle delivered to every launched run as input.approver' },
154
+ { flags: '--agents <list>', description: 'Agent harnesses to allow, as claude[,codex]' },
155
+ { flags: '--name <name>', description: 'Name for the hosted listener' },
156
+ { flags: '--draft', description: 'Create the listener without activating it' },
157
+ NO_CONNECT_OPTION,
158
+ JSON_OPTION,
159
+ ],
160
+ variants: ['deploy', 'cloud-deploy'],
161
+ },
162
+ {
163
+ name: 'deployments',
164
+ description: 'List this workspace’s hosted trigger listeners',
165
+ options: [JSON_OPTION],
166
+ variants: ['deployments'],
167
+ },
168
+ {
169
+ name: 'hn-monitor',
170
+ description: 'Hacker News monitor: poll for matching stories and launch a flow per hit',
171
+ subcommands: [
172
+ {
173
+ name: 'start',
174
+ description: 'Start polling in the foreground',
175
+ args: [{ name: 'spec', description: 'Monitor spec.json', required: true }],
176
+ options: [
177
+ DATA_DIR_OPTION,
178
+ { flags: '--poll-interval-ms <ms>', description: 'Milliseconds between polls' },
179
+ ],
180
+ },
181
+ ],
182
+ variants: ['hn-monitor'],
183
+ },
184
+ {
185
+ name: 'observer',
186
+ description: 'Mint a read-only observer link without running a flow',
187
+ options: [DATA_DIR_OPTION],
188
+ variants: ['observer'],
189
+ },
190
+ {
191
+ name: 'replay',
192
+ description: 'Replay a finished run from its local journal',
193
+ args: [{ name: 'run-id', description: 'Run id to replay', required: true }],
194
+ options: [
195
+ JSON_OPTION,
196
+ DATA_DIR_OPTION,
197
+ { flags: '--at <step-id>', description: 'Replay up to this step' },
198
+ {
199
+ flags: '--allow-human-influenced',
200
+ description: 'Proceed even though the run carries human-influenced state',
201
+ },
202
+ ],
203
+ variants: ['replay'],
204
+ },
205
+ {
206
+ name: 'resume',
207
+ description: 'Resume an interrupted local run from where its journal left off',
208
+ args: [{ name: 'run-id', description: 'Run id to resume', required: true }],
209
+ options: LOCAL_EXECUTION_OPTIONS,
210
+ variants: ['resume'],
211
+ },
212
+ {
213
+ name: 'run',
214
+ description: 'Run a flow locally, or submit it to Cloud with --cloud',
215
+ args: [{ name: 'flow', description: 'flow.yaml, flow.ts, spec.json, or <flow>@sha256:<digest>', required: true }],
216
+ options: [
217
+ ...LOCAL_EXECUTION_OPTIONS,
218
+ { flags: '--input <json-or-file>', description: 'Input for an authored .flow.ts, inline JSON or a file path' },
219
+ { flags: '--bucket <file-bucket-uri>', description: 'File bucket to fetch a sealed bundle from' },
220
+ { flags: '--reuse-from <run-id>', description: 'Reuse memoized step outputs from an earlier run' },
221
+ { flags: '--cloud', description: 'Submit to Agent Relay Cloud instead of running locally' },
222
+ { flags: '--wait', description: 'With --cloud, poll until the hosted run reaches a terminal state' },
223
+ {
224
+ flags: '--sync-code',
225
+ description: 'With --cloud, upload the working directory as the run’s tree; pull results back with `flows sync`',
226
+ },
227
+ {
228
+ flags: '--no-connect',
229
+ description: 'With --cloud, refuse a missing integration instead of offering to connect it',
230
+ },
231
+ ],
232
+ variants: ['run', 'cloud-run'],
233
+ },
234
+ {
235
+ name: 'schedule',
236
+ description: 'Register a flow to run in Cloud on a cron or interval, or on the one it declares',
237
+ args: [{ name: 'flow', description: 'flow.yaml or flow.ts submitted on every fire', required: true }],
238
+ options: [
239
+ {
240
+ flags: '--cron <expr>',
241
+ description: 'Cron expression to fire on; with neither this nor --every, the flow’s own schedule.* handler supplies it',
242
+ },
243
+ { flags: '--every <duration>', description: 'Fixed cadence, as <n><s|m|h|d>; not valid with --cron' },
244
+ { flags: '--tz <iana>', description: 'IANA timezone the cron is read in' },
245
+ { flags: '--input <json-or-file>', description: 'Input for an authored .flow.ts, inline JSON or a file path' },
246
+ { flags: '--name <name>', description: 'Name for the schedule' },
247
+ NO_CONNECT_OPTION,
248
+ JSON_OPTION,
249
+ ],
250
+ variants: ['schedule'],
251
+ },
252
+ {
253
+ name: 'schedules',
254
+ description: 'List this workspace’s Cloud schedules',
255
+ options: [JSON_OPTION],
256
+ variants: ['schedules'],
257
+ },
258
+ {
259
+ name: 'serve-webhook',
260
+ description: 'Run the local webhook receiver that writes provider deliveries into the trigger inbox',
261
+ options: [
262
+ DATA_DIR_OPTION,
263
+ { flags: '--port <port>', description: 'Port to listen on, bound to 127.0.0.1; required' },
264
+ { flags: '--allow <names>', description: 'Comma-separated flow names this receiver admits' },
265
+ ],
266
+ variants: ['serve-webhook'],
267
+ },
268
+ {
269
+ name: 'sync',
270
+ description: 'Apply a hosted run’s code changes to a local tree (replaces `agent-relay cloud sync`)',
271
+ args: [{ name: 'run-id', description: 'Hosted run id whose patch to apply', required: true }],
272
+ options: [
273
+ JSON_OPTION,
274
+ { flags: '--dry-run', description: 'Print the patch and apply nothing' },
275
+ { flags: '--dir <path>', description: 'Tree to apply the patch to', defaultValue: '.' },
276
+ ],
277
+ variants: ['sync'],
278
+ },
279
+ {
280
+ name: 'tick',
281
+ description: 'Interval scheduler: launch a flow on a fixed local cadence',
282
+ subcommands: [
283
+ {
284
+ name: 'start',
285
+ description: 'Start ticking in the foreground',
286
+ args: [{ name: 'spec', description: 'Flow spec.json to launch each tick', required: true }],
287
+ options: [
288
+ DATA_DIR_OPTION,
289
+ { flags: '--schedule-id <id>', description: 'Stable id identifying this schedule' },
290
+ { flags: '--interval-ms <ms>', description: 'Milliseconds between ticks' },
291
+ { flags: '--epoch-ms <ms>', description: 'Epoch the tick grid is aligned to' },
292
+ { flags: '--max-catch-up <n>', description: 'Most missed ticks to replay after a gap' },
293
+ { flags: '--poll-interval-ms <ms>', description: 'Milliseconds between schedule polls' },
294
+ ],
295
+ },
296
+ ],
297
+ variants: ['tick'],
298
+ },
299
+ {
300
+ name: 'undeploy',
301
+ description: 'Remove a hosted trigger listener',
302
+ args: [{ name: 'deployment-id', description: 'Deployment id to remove', required: true }],
303
+ options: [JSON_OPTION],
304
+ variants: ['undeploy'],
305
+ },
306
+ {
307
+ name: 'unschedule',
308
+ description: 'Remove a Cloud schedule, so it stops firing',
309
+ args: [{ name: 'schedule-id', description: 'Schedule id to remove', required: true }],
310
+ options: [JSON_OPTION],
311
+ variants: ['unschedule'],
312
+ },
313
+ ] as const satisfies readonly CliVerbSpec[];
314
+
315
+ /**
316
+ * Every `ParsedArgs` variant claimed by some verb in {@link CLI_VERBS}.
317
+ *
318
+ * Widened from the table rather than written down, so it tracks the table.
319
+ */
320
+ type DeclaredVariant = (typeof CLI_VERBS)[number]['variants'][number];
321
+
322
+ /**
323
+ * Compile-time drift guard, the half a runtime test cannot cover.
324
+ *
325
+ * Adding a variant to `ParsedArgs` without giving some verb a claim on it
326
+ * leaves `Exclude<ParsedArgs['command'], DeclaredVariant>` non-`never`, and
327
+ * this assignment stops compiling. The reverse direction catches a table entry
328
+ * naming a variant that no longer exists.
329
+ */
330
+ type AssertNever<T extends never> = T;
331
+ export type _EveryVariantIsDeclared = AssertNever<Exclude<ParsedArgs['command'], DeclaredVariant>>;
332
+ export type _EveryDeclaredVariantExists = AssertNever<Exclude<DeclaredVariant, ParsedArgs['command']>>;
333
+
334
+ /**
335
+ * The verbs `parseArgs` accepts. A token outside this set is refused before any
336
+ * per-verb parser sees it, which is what keeps dispatch and {@link CLI_VERBS}
337
+ * from drifting apart.
338
+ */
339
+ export const CLI_VERB_NAMES: ReadonlySet<string> = new Set(CLI_VERBS.map((verb) => verb.name));
package/src/cli-watch.ts CHANGED
@@ -9,24 +9,23 @@ const DEBOUNCE_MS = 150;
9
9
  type ExitCode = 0 | 1 | 2 | 3;
10
10
 
11
11
  /** A runner around the ordinary CLI, with a fresh module cache for every check. */
12
- export async function watchCheck(path: string, json: boolean, io: CliIo): Promise<ExitCode> {
13
- const controller = new AbortController();
14
- const stop = (): void => controller.abort();
15
- process.once('SIGINT', stop);
16
- process.once('SIGTERM', stop);
12
+ export async function watchCheck(
13
+ path: string,
14
+ json: boolean,
15
+ io: CliIo,
16
+ /** Cancellation, owned by the caller; this function installs no signal handler. */
17
+ signal: AbortSignal,
18
+ ): Promise<ExitCode> {
17
19
  try {
18
20
  return await watchChecks({
19
21
  path,
20
- signal: controller.signal,
22
+ signal,
21
23
  check: () => checkOnce(path, json, io),
22
24
  clear: () => { if (!json) io.stdout('\x1b[2J\x1b[H'); },
23
25
  });
24
26
  } catch (error) {
25
27
  io.stderr(`REFUSED [input_unreadable] Could not watch "${path}": ${error instanceof Error ? error.message : String(error)}`);
26
28
  return 2;
27
- } finally {
28
- process.off('SIGINT', stop);
29
- process.off('SIGTERM', stop);
30
29
  }
31
30
  }
32
31
 
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ import { parseBuildArgs, runBuild, type BuildArgs } from './cli/build.js';
36
36
  import { runHnMonitor } from './cli/hn-monitor.js';
37
37
  import { runTickRunner } from './cli/tick-runner.js';
38
38
  import { DEFAULT_DATA_DIR } from './daemon-connection.js';
39
+ import { CLI_VERB_NAMES } from './cli-commands.js';
39
40
  import {
40
41
  mintObserverUrl,
41
42
  resolveObserverLinkEnv,
@@ -50,14 +51,19 @@ export interface CliIo {
50
51
  }
51
52
 
52
53
  type CliExitCode = 0 | 1 | 2 | 3;
53
- type ParsedArgs =
54
+
55
+ /**
56
+ * Every shape `parseArgs` can produce. Exported for `cli-commands.ts`, whose
57
+ * table must claim each variant or fail to compile.
58
+ */
59
+ export type ParsedArgs =
54
60
  | { command: 'add'; value: string }
55
61
  | ReplayArgs
56
62
  | BuildArgs
57
63
  | DeployArgs
58
64
  | { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
59
65
  | { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean }
60
- | { command: 'sync'; runId: string; json: boolean; root: string }
66
+ | { command: 'sync'; runId: string; json: boolean; root: string; dryRun: boolean }
61
67
  | CloudDeployArgs
62
68
  | { command: 'deployments'; json: boolean }
63
69
  | { command: 'undeploy'; agentId: string; json: boolean }
@@ -92,7 +98,7 @@ const USAGE = [
92
98
  'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
93
99
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
94
100
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
95
- 'flows sync [--json] [--dir <path>] <run-id>',
101
+ 'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
96
102
  'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
97
103
  'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
98
104
  'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
@@ -117,9 +123,48 @@ const PROCESS_IO: CliIo = {
117
123
  stderr: (line) => process.stderr.write(`${line}\n`),
118
124
  };
119
125
 
126
+ /** Optional knobs for an embedded caller. `bin/flows.js` passes none. */
127
+ export interface RunCliOptions {
128
+ /**
129
+ * Cancellation for the long-running verbs (`run --cloud`, `check --watch`,
130
+ * `serve-webhook`, `hn-monitor start`, `tick start`).
131
+ *
132
+ * Supply one and `runCli` installs **no** process signal handlers -- required
133
+ * of a CLI surface mounted into another host, which owns SIGINT itself.
134
+ * Omit it and the standalone `flows` binary keeps today's behaviour exactly:
135
+ * SIGINT/SIGTERM are handled here, for the duration of that verb only.
136
+ */
137
+ signal?: AbortSignal;
138
+ }
139
+
140
+ /**
141
+ * Run one long-running verb under a cancellation signal.
142
+ *
143
+ * With a caller-supplied signal this installs nothing. Without one it owns
144
+ * SIGINT/SIGTERM for the duration of `body` and removes the handlers after --
145
+ * the pre-existing standalone behaviour, unchanged.
146
+ */
147
+ async function withInterrupt<T>(
148
+ provided: AbortSignal | undefined,
149
+ body: (signal: AbortSignal) => Promise<T>,
150
+ ): Promise<T> {
151
+ if (provided !== undefined) return body(provided);
152
+ const controller = new AbortController();
153
+ const onSignal = (): void => controller.abort();
154
+ process.once('SIGINT', onSignal);
155
+ process.once('SIGTERM', onSignal);
156
+ try {
157
+ return await body(controller.signal);
158
+ } finally {
159
+ process.off('SIGINT', onSignal);
160
+ process.off('SIGTERM', onSignal);
161
+ }
162
+ }
163
+
120
164
  export async function runCli(
121
165
  args: readonly string[],
122
166
  io: CliIo = PROCESS_IO,
167
+ options: RunCliOptions = {},
123
168
  ): Promise<CliExitCode> {
124
169
  if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
125
170
  io.stdout(USAGE);
@@ -135,9 +180,13 @@ export async function runCli(
135
180
 
136
181
  if (parsed.command === 'add') return addPlugin(parsed.value, io);
137
182
 
138
- if (parsed.command === 'serve-webhook') return runServeWebhook(parsed, io);
183
+ if (parsed.command === 'serve-webhook') {
184
+ return withInterrupt(options.signal, (signal) => runServeWebhook(parsed, io, signal));
185
+ }
139
186
 
140
- if (parsed.command === 'cloud-run') return runCloudCli(parsed, io);
187
+ if (parsed.command === 'cloud-run') {
188
+ return withInterrupt(options.signal, (signal) => runCloudCli(parsed, io, signal));
189
+ }
141
190
  if (parsed.command === 'sync') return runCloudSyncCli(parsed, io);
142
191
  if (parsed.command === 'cloud-deploy') return runCloudDeployCli(parsed, io);
143
192
  if (parsed.command === 'deployments') return runCloudDeploymentsCli(parsed, io);
@@ -159,7 +208,9 @@ export async function runCli(
159
208
  if (parsed.command === 'deploy') return runDeploy(parsed, io);
160
209
 
161
210
  if (parsed.command === 'check') {
162
- if (parsed.watch) return watchCheck(parsed.value, parsed.json, io);
211
+ if (parsed.watch) {
212
+ return withInterrupt(options.signal, (signal) => watchCheck(parsed.value, parsed.json, io, signal));
213
+ }
163
214
  // Deliberately daemon-free (kernel/DAEMON-LIFECYCLE.md §4). `checkFlow` is
164
215
  // a compile-and-preflight that opens no daemon socket, and the parser
165
216
  // refuses `--data-dir` on `check`, so there is no data dir to attach to.
@@ -174,45 +225,27 @@ export async function runCli(
174
225
  if (parsed.command === 'observer') return runObserverCommand(io);
175
226
 
176
227
  if (parsed.command === 'hn-monitor') {
177
- const controller = new AbortController();
178
- const onSignal = (): void => controller.abort();
179
- process.once('SIGINT', onSignal);
180
- process.once('SIGTERM', onSignal);
181
- try {
182
- return await runHnMonitor({
183
- dataDir: parsed.dataDir,
184
- specPath: parsed.specPath,
185
- pollIntervalMs: parsed.pollIntervalMs,
186
- signal: controller.signal,
187
- }, io);
188
- } finally {
189
- process.off('SIGINT', onSignal);
190
- process.off('SIGTERM', onSignal);
191
- }
228
+ return withInterrupt(options.signal, (signal) => runHnMonitor({
229
+ dataDir: parsed.dataDir,
230
+ specPath: parsed.specPath,
231
+ pollIntervalMs: parsed.pollIntervalMs,
232
+ signal,
233
+ }, io));
192
234
  }
193
235
 
194
236
  if (parsed.command === 'tick') {
195
- const controller = new AbortController();
196
- const onSignal = (): void => controller.abort();
197
- process.once('SIGINT', onSignal);
198
- process.once('SIGTERM', onSignal);
199
- try {
200
- return await runTickRunner({
201
- dataDir: parsed.dataDir,
202
- specPath: parsed.specPath,
203
- schedule: {
204
- scheduleId: parsed.scheduleId,
205
- intervalMs: parsed.intervalMs,
206
- ...(parsed.epochMs === undefined ? {} : { epochMs: parsed.epochMs }),
207
- ...(parsed.maxCatchUp === undefined ? {} : { maxCatchUp: parsed.maxCatchUp }),
208
- },
209
- pollIntervalMs: parsed.pollIntervalMs,
210
- signal: controller.signal,
211
- }, io) as CliExitCode;
212
- } finally {
213
- process.off('SIGINT', onSignal);
214
- process.off('SIGTERM', onSignal);
215
- }
237
+ return withInterrupt(options.signal, async (signal) => await runTickRunner({
238
+ dataDir: parsed.dataDir,
239
+ specPath: parsed.specPath,
240
+ schedule: {
241
+ scheduleId: parsed.scheduleId,
242
+ intervalMs: parsed.intervalMs,
243
+ ...(parsed.epochMs === undefined ? {} : { epochMs: parsed.epochMs }),
244
+ ...(parsed.maxCatchUp === undefined ? {} : { maxCatchUp: parsed.maxCatchUp }),
245
+ },
246
+ pollIntervalMs: parsed.pollIntervalMs,
247
+ signal,
248
+ }, io) as CliExitCode);
216
249
  }
217
250
 
218
251
  // Attach-or-spawn runs inside `runFlow`/`resumeFlow`/`runDirectFlow`, at the
@@ -458,6 +491,11 @@ function emitWait(
458
491
 
459
492
  function parseArgs(args: readonly string[]): ParsedArgs | undefined {
460
493
  const command = args[0];
494
+ // The verb set lives in exactly one place -- `CLI_VERBS` in cli-commands.ts --
495
+ // which is also what `createRelayCliSurface` projects into `commands`. Gating
496
+ // dispatch on it means a token the surface does not declare can never reach a
497
+ // parser, so the declared tree and the dispatched tree cannot drift apart.
498
+ if (command === undefined || !CLI_VERB_NAMES.has(command)) return undefined;
461
499
  if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
462
500
  if (command === 'replay') return parseReplayArgs(args.slice(1));
463
501
  if (command === 'build') return parseBuildArgs(args.slice(1));
@@ -718,9 +756,13 @@ function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
718
756
  * directory at all -- the mint is a pure Relaycast API round-trip. No
719
757
  * positional argument, no other flags.
720
758
  */
721
- /** `flows sync [--json] [--dir <path>] <run-id>`: apply a hosted run's patch to a local tree. */
759
+ /**
760
+ * `flows sync [--json] [--dry-run] [--dir <path>] <run-id>`: apply a hosted
761
+ * run's patch to a local tree, or with `--dry-run` print it and apply nothing.
762
+ */
722
763
  function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
723
764
  let json = false;
765
+ let dryRun = false;
724
766
  let root: string | undefined;
725
767
  const positionals: string[] = [];
726
768
  for (let index = 0; index < args.length; index += 1) {
@@ -730,6 +772,11 @@ function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
730
772
  json = true;
731
773
  continue;
732
774
  }
775
+ if (argument === '--dry-run') {
776
+ if (dryRun) return undefined;
777
+ dryRun = true;
778
+ continue;
779
+ }
733
780
  if (argument === '--dir') {
734
781
  const value = args[index + 1];
735
782
  if (root !== undefined || value === undefined || value.startsWith('-')) return undefined;
@@ -741,7 +788,7 @@ function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
741
788
  positionals.push(argument);
742
789
  }
743
790
  if (positionals.length !== 1) return undefined;
744
- return { command: 'sync', runId: positionals[0]!, json, root: root ?? '.' };
791
+ return { command: 'sync', runId: positionals[0]!, json, dryRun, root: root ?? '.' };
745
792
  }
746
793
 
747
794
  function parseObserverArgs(rest: readonly string[]): ParsedArgs | undefined {
@@ -956,3 +1003,15 @@ if (isDirectInvocation(process.argv[1])) {
956
1003
  process.exitCode = exitCode;
957
1004
  });
958
1005
  }
1006
+
1007
+ /**
1008
+ * The argv parser, exported for the CLI-surface drift test.
1009
+ *
1010
+ * The drift test must prove that every command `cli-commands.ts` declares
1011
+ * actually routes to a `ParsedArgs` variant, and that every variant is
1012
+ * reachable from some declared command. Observing that through `runCli` would
1013
+ * mean executing the commands. Not part of the package's public API --
1014
+ * `@relayflows/sdk/cli` exports `runCli`, and `@relayflows/sdk/relay-cli`
1015
+ * exports the surface.
1016
+ */
1017
+ export { parseArgs as parseCliArgs };