@source-repo/rpc-cli 4.6.0 → 5.0.0

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 (46) hide show
  1. package/README.md +1 -0
  2. package/dist/console.d.ts +15 -2
  3. package/dist/console.d.ts.map +1 -1
  4. package/dist/console.js +49 -15
  5. package/dist/console.js.map +1 -1
  6. package/dist/console.types.json +459 -0
  7. package/dist/credentials.d.ts +77 -0
  8. package/dist/credentials.d.ts.map +1 -0
  9. package/dist/credentials.js +118 -0
  10. package/dist/credentials.js.map +1 -0
  11. package/dist/enrolment.d.ts +91 -0
  12. package/dist/enrolment.d.ts.map +1 -0
  13. package/dist/enrolment.js +94 -0
  14. package/dist/enrolment.js.map +1 -0
  15. package/dist/extract.d.ts.map +1 -1
  16. package/dist/extract.js +69 -3
  17. package/dist/extract.js.map +1 -1
  18. package/dist/grants.d.ts +25 -0
  19. package/dist/grants.d.ts.map +1 -0
  20. package/dist/grants.js +62 -0
  21. package/dist/grants.js.map +1 -0
  22. package/dist/index.js +219 -71
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp.d.ts.map +1 -1
  25. package/dist/mcp.js +323 -1
  26. package/dist/mcp.js.map +1 -1
  27. package/dist/network.d.ts +47 -1
  28. package/dist/network.d.ts.map +1 -1
  29. package/dist/network.js +17 -1
  30. package/dist/network.js.map +1 -1
  31. package/dist/node.d.ts +10 -0
  32. package/dist/node.d.ts.map +1 -1
  33. package/dist/node.js +12 -0
  34. package/dist/node.js.map +1 -1
  35. package/dist/pairing.d.ts +52 -0
  36. package/dist/pairing.d.ts.map +1 -0
  37. package/dist/pairing.js +57 -0
  38. package/dist/pairing.js.map +1 -0
  39. package/dist/tasks.d.ts +189 -0
  40. package/dist/tasks.d.ts.map +1 -0
  41. package/dist/tasks.js +485 -0
  42. package/dist/tasks.js.map +1 -0
  43. package/dist/web/app.css +1 -1
  44. package/dist/web/app.js +12 -12
  45. package/dist/web/app.js.map +1 -1
  46. package/package.json +7 -5
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import { basename, resolve } from 'node:path';
5
- import { createDerivedAuthenticator, createHmacSigner, createHmacVerifier, createTokenAuthenticator, firstAuthenticator, mintDerivedCredential, defaultSecureWebPort, defaultSecureWebSocketPort, defaultWebPort, defaultWebSocketPort, namespaceProblems, readableNameFor } from '@source-repo/rpc';
5
+ import { createDerivedAuthenticator, createTokenAuthenticator, firstAuthenticator, defaultSecureWebPort, defaultSecureWebSocketPort, defaultWebPort, defaultWebSocketPort, namespaceProblems, readableNameFor } from '@source-repo/rpc';
6
6
  import { extractSchema } from './extract.js';
7
7
  import { startConsole } from './console.js';
8
8
  import { startBroker } from './broker.js';
@@ -15,6 +15,9 @@ import { startFake } from './fake.js';
15
15
  import { replaySession, startRecording } from './record.js';
16
16
  import { checkPeer, diffPeers } from './conform.js';
17
17
  import { bench, benchArguments } from './bench.js';
18
+ import { loadAuthFile, loadSigningKeys, loadTls, scriptCredentials } from './credentials.js';
19
+ import { grantLines, loadAiGrants } from './grants.js';
20
+ import { defaultTaskFile, startTaskFile, taskFileSkeleton, taskFileSkeletonNotes } from './tasks.js';
18
21
  /**
19
22
  * msgrpc extract - read the contract out of TypeScript source and write it to a file
20
23
  * msgrpc check - compare the source against a written contract and report breaking changes
@@ -32,6 +35,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
32
35
  console browse a live network in a browser: peers, what they expose, calls and events
33
36
  broker run a WebSocket bus: relays between the peers that connect to it, until Ctrl-C
34
37
  node make this machine scriptable from another one, and nothing else, until Ctrl-C
38
+ run start console, node and serve roles together from one JSON task file, until Ctrl-C
39
+ with no file named it runs ./source-rpc.tasks.json; --init writes one to start from
35
40
  mcp serve the network to an MCP client over stdio: list peers, describe them, call them
36
41
  stdio carries the protocol, so it is not for interactive use; --port opens a second
37
42
  door over streamable HTTP, so two clients can share one node
@@ -70,6 +75,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
70
75
 
71
76
  peers / describe / call / watch
72
77
  --broker <url> an MQTT network, e.g. mqtt://localhost:1883
78
+ SOURCE_RPC_MQTT_USERNAME and SOURCE_RPC_MQTT_PASSWORD are used
79
+ as broker credentials when set
73
80
  --hub <url> a socket.io network, e.g. http://hub:7843
74
81
  one of --broker and --hub is required; both watches both
75
82
  --prefix <topic> topic namespace, default the transport's own
@@ -86,6 +93,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
86
93
 
87
94
  console
88
95
  --broker <url> an MQTT network, e.g. mqtt://localhost:1883
96
+ SOURCE_RPC_MQTT_USERNAME and SOURCE_RPC_MQTT_PASSWORD are used
97
+ as broker credentials when set
89
98
  --hub <url> a socket.io network, e.g. http://hub:7843
90
99
  one of --broker and --hub is required; both watches both
91
100
  --prefix <topic> topic namespace, default the transport's own
@@ -102,6 +111,8 @@ const usage = `source-rpc <command> [options] --version prints the CLI
102
111
 
103
112
  mcp
104
113
  --broker <url> an MQTT network
114
+ SOURCE_RPC_MQTT_USERNAME and SOURCE_RPC_MQTT_PASSWORD are used
115
+ as broker credentials when set
105
116
  --hub <url> a socket.io network
106
117
  one of --broker and --hub is required; both watches both
107
118
  --prefix <topic> topic namespace, default the transport's own
@@ -122,6 +133,7 @@ const usage = `source-rpc <command> [options] --version prints the CLI
122
133
  nothing can script it. The peer must authenticate as that name,
123
134
  so the key it presents reaches it out of band - deliberately not
124
135
  something this bus can hand over
136
+ --grants <file> what an AI principal may do here; see node --grants above
125
137
  --port <n> serve streamable HTTP here as a second door beside stdio, so a
126
138
  second client shares this node's scripts, fakes and watches
127
139
  rather than forking them. No default: absent means stdio only
@@ -162,6 +174,19 @@ const usage = `source-rpc <command> [options] --version prints the CLI
162
174
  on a broker, --sign at both ends is what makes the grant work:
163
175
  without it nothing can prove who a caller is and every call is
164
176
  refused
177
+ --grants <file> what an AI principal may do here, as a grants document. Without one
178
+ a badged principal may observe and nothing else, which is the
179
+ default everywhere. SIGHUP re-reads it, so a grant can be closed
180
+ without stopping the node
181
+
182
+ run [<file.json>] defaults to ./source-rpc.tasks.json, in this directory only
183
+ shared network settings and console, node or serve tasks; relative paths are resolved from the
184
+ task file. Each task's credentials are its own: a key file under 'sign', an auth file under
185
+ 'auth', or the same secrets written inline in either. Full format:
186
+ https://source-repo.github.io/rpc/tools/cli#task-files
187
+ --init write a task file to start from, with three roles and fresh signing
188
+ secrets, and refuse to write over one that exists. --broker, --hub
189
+ and --scriptable-by fill in what they name
165
190
 
166
191
  strip <file…>
167
192
  --out <dir> where each decorator-free twin lands, under the same file name.
@@ -252,30 +277,26 @@ const withHistory = (next, previous) => {
252
277
  }
253
278
  return next;
254
279
  };
280
+ /**
281
+ * HMAC keys for the console, read from a file rather than a flag: a secret on the command line is
282
+ * visible to anyone who can run ps.
283
+ *
284
+ * { "name": "console-1", "secret": "…", "peers": { "plantServer": "…" } }
285
+ *
286
+ * `peers` is optional. Supplying it makes the console check signatures on what it receives too,
287
+ * which means an unsigned peer's frames are then dropped.
288
+ */
255
289
  const readSigningKeys = (path, command) => {
256
- let keys;
257
290
  try {
258
- keys = JSON.parse(readFileSync(path, 'utf8'));
291
+ const signing = loadSigningKeys(path);
292
+ if (signing.readableByOthers)
293
+ process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
294
+ return signing;
259
295
  }
260
296
  catch (e) {
261
- process.stderr.write(`source-rpc ${command}: cannot read keys from ${path}: ${e.message}\n`);
262
- process.exit(1);
263
- }
264
- if (typeof keys.secret !== 'string' || !keys.secret) {
265
- process.stderr.write(`source-rpc ${command}: ${path} has no "secret"\n`);
297
+ process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
266
298
  process.exit(1);
267
299
  }
268
- try {
269
- // Worth saying out loud: this file is the console's identity on the network.
270
- if (statSync(path).mode & 0o077)
271
- process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
272
- }
273
- catch {
274
- // Not worth failing over if the mode cannot be read.
275
- }
276
- const sign = createHmacSigner(keys.secret);
277
- const verify = keys.peers ? createHmacVerifier((peer) => keys.peers?.[peer]) : undefined;
278
- return { keys, sign, verify };
279
300
  };
280
301
  /**
281
302
  * Bearer tokens, read from a file or the environment. Never a flag, for the same reason the signing
@@ -299,37 +320,68 @@ const readSigningKeys = (path, command) => {
299
320
  * is now impossible either way. Lifetimes are short and renewal does not exist, so stopping the
300
321
  * node means its scripts' credentials expire on their own; immediate revocation is the grants
301
322
  * work, not this.
323
+ *
324
+ * The minting itself lives in credentials.ts, because `run` mints the same credentials from a task
325
+ * file and two implementations of a credential are two things to get subtly different.
302
326
  */
303
- const scriptCredentials = (auth, issuer, command) => {
304
- if (!auth.derive)
305
- return undefined;
306
- if (!auth.token && !auth.tokens && !auth.issuers)
307
- process.stderr.write(`source-rpc ${command}: 'derive' is set but nothing else in the auth file is - scripts will present credentials to a bus that may not be checking any.\n`);
308
- return async (script) => {
309
- const name = `${script}@${issuer}`;
310
- const issuedAt = Date.now();
311
- return {
312
- name,
313
- token: await mintDerivedCredential({
314
- credentialId: `${script}-${issuedAt.toString(36)}`,
315
- subject: name,
316
- // The provenance the AI boundary reads. A script is a program this node started,
317
- // whoever wrote it - the honest claim, and never a claim about what wrote it.
318
- roles: ['ai-program'],
319
- issuer,
320
- generation: 2,
321
- issuedAt,
322
- expiresAt: issuedAt + SCRIPT_CREDENTIAL_MS
323
- }, auth.derive)
324
- };
325
- };
327
+ const scriptCredentialsFor = (auth, issuer, command) => scriptCredentials(auth, issuer, (message) => process.stderr.write(`source-rpc ${command}: ${message}\n`));
328
+ const readAiGrants = (path, command) => {
329
+ try {
330
+ return loadAiGrants(path);
331
+ }
332
+ catch (e) {
333
+ process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
334
+ process.exit(1);
335
+ }
326
336
  };
327
337
  /**
328
- * How long a script's credential lasts. Deliberately short of a working day: a credential that
329
- * outlives the run it was minted for is the failure this design exists to avoid, and a script that
330
- * needs longer should be a peer with a credential an operator issued.
338
+ * Refusals reach the operator; permitted calls do not.
339
+ *
340
+ * Both are audit, and both belong in the fleet-side sink rather than here - but of the two, a
341
+ * refusal is the one somebody is standing at a terminal wondering about, and printing every allowed
342
+ * call would bury it. The sentence the library supplies is the whole line: it already says which
343
+ * grant was wanted and why the answer was no.
331
344
  */
332
- const SCRIPT_CREDENTIAL_MS = 4 * 60 * 60 * 1000;
345
+ const aiDecisionReporter = (command) => (record) => {
346
+ if (!record.allowed)
347
+ process.stderr.write(`source-rpc ${command}: refused ${record.source} calling ${record.method}: ${record.reason}\n`);
348
+ };
349
+ /**
350
+ * Re-read the grants document on SIGHUP, so a grant can be closed without restarting the node.
351
+ *
352
+ * A signal rather than a file watcher, deliberately. A watcher fires on a half-written file and
353
+ * has to be taught what an atomic replace looks like on three platforms; a signal is an operator
354
+ * saying *now*, which is the same instinct as everything else here - a change in what is permitted
355
+ * is something somebody states rather than something that happens when a file is touched.
356
+ *
357
+ * A failed reload keeps the document that was already in force. The alternative - falling back to
358
+ * no document - reads as "closed, therefore safe" and is in fact the node quietly disagreeing with
359
+ * the policy its operator believes is loaded.
360
+ */
361
+ const reloadGrantsOnHangUp = (path, initial, apply, command) => {
362
+ // Not every platform has SIGHUP, and Windows has none of this. Nothing else is affected.
363
+ if (process.platform === 'win32')
364
+ return;
365
+ let current = initial;
366
+ process.on('SIGHUP', () => {
367
+ let next;
368
+ try {
369
+ next = loadAiGrants(path);
370
+ }
371
+ catch (e) {
372
+ process.stderr.write(`source-rpc ${command}: grants unchanged, ${e instanceof Error ? e.message : String(e)}\n`);
373
+ return;
374
+ }
375
+ // The revision exists so a rollback is visible. Applied anyway - an operator may be
376
+ // deliberately reverting - but never silently, since the other cause is a stale file.
377
+ if (current && next.revision < current.revision)
378
+ process.stderr.write(`source-rpc ${command}: grants revision went backwards, ${current.revision} to ${next.revision}\n`);
379
+ current = next;
380
+ apply(next);
381
+ for (const line of grantLines(next))
382
+ process.stderr.write(`source-rpc ${command}: ${line}\n`);
383
+ });
384
+ };
333
385
  /**
334
386
  * Certificate and key for a server this command opens, or undefined for plain HTTP.
335
387
  *
@@ -349,10 +401,10 @@ const readTls = (argv, command) => {
349
401
  process.exit(1);
350
402
  }
351
403
  try {
352
- return { cert: readFileSync(cert), key: readFileSync(key) };
404
+ return loadTls(cert, key);
353
405
  }
354
406
  catch (e) {
355
- process.stderr.write(`source-rpc ${command}: cannot read the certificate or key: ${e.message}\n`);
407
+ process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
356
408
  process.exit(1);
357
409
  }
358
410
  };
@@ -383,29 +435,17 @@ const readAuth = (argv, command) => {
383
435
  ...(tokens ? { tokens } : {})
384
436
  };
385
437
  }
386
- let auth;
387
- try {
388
- auth = JSON.parse(readFileSync(path, 'utf8'));
389
- }
390
- catch (e) {
391
- process.stderr.write(`source-rpc ${command}: cannot read tokens from ${path}: ${e.message}\n`);
392
- process.exit(1);
393
- }
394
- if (!auth.token && !auth.tokens) {
395
- // An empty file is the failure that looks like success: the command starts, and the bus it
396
- // meant to gate is open. Better to refuse than to run unauthenticated on request.
397
- process.stderr.write(`source-rpc ${command}: ${path} has neither "token" nor "tokens"\n`);
398
- process.exit(1);
399
- }
400
438
  try {
439
+ const loaded = loadAuthFile(path);
401
440
  // Worth saying out loud: whoever can read this file can be these peers.
402
- if (statSync(path).mode & 0o077)
441
+ if (loaded.readableByOthers)
403
442
  process.stderr.write(`source-rpc ${command}: ${path} is readable by other users\n`);
443
+ return loaded.auth;
404
444
  }
405
- catch {
406
- // Not worth failing over if the mode cannot be read.
445
+ catch (e) {
446
+ process.stderr.write(`source-rpc ${command}: ${e instanceof Error ? e.message : String(e)}\n`);
447
+ process.exit(1);
407
448
  }
408
- return auth;
409
449
  };
410
450
  /**
411
451
  * The flags every command that joins a network takes, read once.
@@ -915,17 +955,24 @@ const runMcp = async (argv) => {
915
955
  process.exit(1);
916
956
  }
917
957
  }
918
- const credentialFor = scriptsDir ? scriptCredentials(readAuth(argv, 'mcp'), network.name, 'mcp') : undefined;
958
+ const credentialFor = scriptsDir ? scriptCredentialsFor(readAuth(argv, 'mcp'), network.name, 'mcp') : undefined;
959
+ const grantsPath = argument(argv, '--grants', '');
960
+ const aiGrants = grantsPath ? readAiGrants(grantsPath, 'mcp') : undefined;
919
961
  const running = await startMcp({ ...network, ...(contracts ? { contracts: resolve(contracts) } : {}), ...(argv.includes('--allow-exec') ? { allowExec: true } : {}),
920
962
  ...(scriptsDir ? { scripts: resolve(scriptsDir) } : {}),
921
963
  ...(credentialFor ? { credentialFor } : {}),
922
964
  ...(scriptableBy.length ? { scriptableBy } : {}),
965
+ ...(aiGrants ? { aiGrants } : {}),
966
+ onAiDecision: aiDecisionReporter('mcp'),
923
967
  ...(doorPort ? { port: doorPort, host: doorHost, ...(doorToken ? { doorToken } : {}) } : {}) }).catch((e) => {
924
968
  // The refusal a wide bind without a token earns arrives here, with its sentence intact.
925
969
  process.stderr.write(`source-rpc ${e.message}\n`);
926
970
  process.exit(1);
927
971
  });
928
972
  // Nothing is written to stdout here: it carries the protocol. See mcp.ts.
973
+ if (scriptsDir || aiGrants)
974
+ for (const line of grantLines(aiGrants))
975
+ process.stderr.write(`source-rpc mcp: ${line}\n`);
929
976
  const stop = () => void running
930
977
  .close()
931
978
  .then(() => process.exit(0))
@@ -945,8 +992,17 @@ const runNode = async (argv) => {
945
992
  process.stderr.write('source-rpc node: needs --scripts <dir> and at least one --scriptable-by <peer>, or it offers nothing to anybody\n');
946
993
  process.exit(1);
947
994
  }
948
- const credentialFor = scriptCredentials(readAuth(argv, 'node'), network.name, 'node');
949
- const running = await startNode({ ...network, scripts: resolve(scriptsDir), scriptableBy, ...(credentialFor ? { credentialFor } : {}) }).catch((e) => {
995
+ const credentialFor = scriptCredentialsFor(readAuth(argv, 'node'), network.name, 'node');
996
+ const grantsPath = argument(argv, '--grants', '');
997
+ const aiGrants = grantsPath ? readAiGrants(grantsPath, 'node') : undefined;
998
+ const running = await startNode({
999
+ ...network,
1000
+ scripts: resolve(scriptsDir),
1001
+ scriptableBy,
1002
+ ...(credentialFor ? { credentialFor } : {}),
1003
+ ...(aiGrants ? { aiGrants } : {}),
1004
+ onAiDecision: aiDecisionReporter('node')
1005
+ }).catch((e) => {
950
1006
  process.stderr.write(`source-rpc node: cannot start: ${e.message}\n`);
951
1007
  process.exit(1);
952
1008
  });
@@ -963,6 +1019,12 @@ const runNode = async (argv) => {
963
1019
  // node is unreachable for the thing it exists to do. Said now rather than discovered as a
964
1020
  // Forbidden on the other machine.
965
1021
  process.stderr.write('source-rpc node: on a broker without --sign nothing can prove who a caller is, so every scripting call will be refused. Give both ends keys.\n');
1022
+ // Said whether or not a document was given, because closed-by-default means "it is running" and
1023
+ // "it can do something" are separately true, and this node's scripts carry `ai-program`.
1024
+ for (const line of grantLines(aiGrants))
1025
+ process.stderr.write(`source-rpc node: ${line}\n`);
1026
+ if (grantsPath)
1027
+ reloadGrantsOnHangUp(grantsPath, aiGrants, running.setAiGrants, 'node');
966
1028
  const stop = () => void running
967
1029
  .close()
968
1030
  .then(() => process.exit(0))
@@ -1002,6 +1064,79 @@ const runConsole = async (argv) => {
1002
1064
  process.on('SIGINT', stop);
1003
1065
  process.on('SIGTERM', stop);
1004
1066
  };
1067
+ const taskStartedLine = (task) => {
1068
+ if (task.type === 'console')
1069
+ return `${task.id}: console ${task.name} on ${task.url}`;
1070
+ if (task.type === 'node')
1071
+ return `${task.id}: node ${task.name}`;
1072
+ return `${task.id}: serve ${task.name} answering ${task.namespaces?.join(', ')}`;
1073
+ };
1074
+ /**
1075
+ * Writes a task file to start from, and refuses to write over one that is already there.
1076
+ *
1077
+ * Refusing matters more here than it usually does: the file it would replace holds signing secrets,
1078
+ * and overwriting it does not lose a configuration that can be typed again - it loses the identity
1079
+ * every other machine on the network was told to expect, and does it silently.
1080
+ */
1081
+ const initTaskFile = (argv, file) => {
1082
+ const skeleton = taskFileSkeleton({
1083
+ ...(argument(argv, '--broker', '') ? { broker: argument(argv, '--broker', '') } : {}),
1084
+ ...(argument(argv, '--hub', '') ? { hub: argument(argv, '--hub', '') } : {}),
1085
+ ...(argument(argv, '--scriptable-by', '') ? { controller: argument(argv, '--scriptable-by', '') } : {})
1086
+ });
1087
+ try {
1088
+ // wx rather than a check and a write: between the two there is a window, and the thing in it
1089
+ // is a key file.
1090
+ writeFileSync(file, `${JSON.stringify(skeleton, undefined, 4)}\n`, { flag: 'wx', mode: 0o600 });
1091
+ }
1092
+ catch (e) {
1093
+ const already = e.code === 'EEXIST';
1094
+ process.stderr.write(`source-rpc run: ${already ? `${file} already exists, and it may hold this host's signing secrets - name a new file or move that one aside` : `cannot write ${file}: ${e.message}`}\n`);
1095
+ process.exit(1);
1096
+ }
1097
+ for (const note of taskFileSkeletonNotes(file, argument(argv, '--scriptable-by', 'controller')))
1098
+ process.stdout.write(`source-rpc run: ${note}\n`);
1099
+ };
1100
+ const runTasks = async (argv) => {
1101
+ const [, named, ...extra] = positionals(argv);
1102
+ if (extra.length) {
1103
+ process.stderr.write(`source-rpc run: give it one task file, or none to use ./${defaultTaskFile}\n`);
1104
+ process.exit(1);
1105
+ }
1106
+ const file = named ?? defaultTaskFile;
1107
+ if (argv.includes('--init'))
1108
+ return initTaskFile(argv, file);
1109
+ // Checked before startTaskFile so that the answer to "run" with nothing set up is the thing to
1110
+ // do next, rather than an ENOENT for a file the operator never mentioned.
1111
+ if (!named && !existsSync(file)) {
1112
+ process.stderr.write(`source-rpc run: no ${defaultTaskFile} here, and no task file named. Write one with 'source-rpc run --init', or name one.\n`);
1113
+ process.exit(1);
1114
+ }
1115
+ const running = await startTaskFile(file, {
1116
+ started: (task) => process.stdout.write(`source-rpc run: started ${taskStartedLine(task)}\n`),
1117
+ warning: (message) => process.stderr.write(`source-rpc run: ${message}\n`)
1118
+ });
1119
+ process.stdout.write(`source-rpc run: ${running.tasks.length} tasks running from ${running.file}\n`);
1120
+ let stopping = false;
1121
+ const stop = () => {
1122
+ if (stopping)
1123
+ return;
1124
+ stopping = true;
1125
+ void running
1126
+ .close()
1127
+ .then(() => process.exit(0))
1128
+ .catch((e) => {
1129
+ process.stderr.write(`source-rpc run: shutdown failed: ${e instanceof Error ? e.message : String(e)}\n`);
1130
+ process.exit(1);
1131
+ });
1132
+ };
1133
+ process.on('SIGINT', stop);
1134
+ process.on('SIGTERM', stop);
1135
+ // The same hang-up that re-reads a node's grants, for every node this file started.
1136
+ if (process.platform !== 'win32')
1137
+ process.on('SIGHUP', () => running.reloadGrants());
1138
+ await new Promise(() => { });
1139
+ };
1005
1140
  const main = () => {
1006
1141
  // `source-rpc describe plantServer | head -4` closes stdout while there is still output to
1007
1142
  // write, and Node turns that into an unhandled 'error' event: a stack trace where a command
@@ -1033,7 +1168,10 @@ const main = () => {
1033
1168
  process.exit(1);
1034
1169
  };
1035
1170
  if (command === 'strip') {
1036
- const files = positionals(argv);
1171
+ // Past the command word, as every other verb here does. Without the slice the first file
1172
+ // to be stripped is one called `strip`, which fails as a missing file rather than saying
1173
+ // no file was named - and it means the command has never worked from the command line.
1174
+ const files = positionals(argv).slice(1);
1037
1175
  const out = argument(argv, '--out', '');
1038
1176
  if (!files.length || !out) {
1039
1177
  process.stderr.write('source-rpc strip: give it one or more .ts files and --out <dir>, e.g. strip scripts/hello.ts --out scripts/stripped\n');
@@ -1069,6 +1207,10 @@ const main = () => {
1069
1207
  void runNode(argv).catch(fail);
1070
1208
  return;
1071
1209
  }
1210
+ if (command === 'run') {
1211
+ void runTasks(argv).catch(fail);
1212
+ return;
1213
+ }
1072
1214
  if (command === 'console') {
1073
1215
  void runConsole(argv).catch(fail);
1074
1216
  return;
@@ -1119,6 +1261,12 @@ const main = () => {
1119
1261
  }
1120
1262
  if (command !== 'extract' && command !== 'check') {
1121
1263
  process.stderr.write(usage);
1264
+ // Pointed at rather than started. Typing the bare command is what someone does to see what
1265
+ // this is, and answering that by joining a bus under whatever identities happen to be in
1266
+ // this directory - and opening a console, and possibly making the machine scriptable -
1267
+ // would be a great deal to have happen while reading the help.
1268
+ if (!command && existsSync(defaultTaskFile))
1269
+ process.stderr.write(`\nthere is a ${defaultTaskFile} here: 'source-rpc run' starts it\n`);
1122
1270
  process.exit(command ? 1 : 0);
1123
1271
  }
1124
1272
  const { schema, diagnostics } = extractSchema(project);