@volter/supercode-teams 0.2.20

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 (79) hide show
  1. package/README.md +119 -0
  2. package/attention.mjs +191 -0
  3. package/bin/fleet.mjs +151 -0
  4. package/bin/teams.mjs +291 -0
  5. package/bin/workspace/args.mjs +66 -0
  6. package/bin/workspace/files.mjs +110 -0
  7. package/bin/workspace/lifecycle.mjs +113 -0
  8. package/bin/workspace/volter-device.mjs +42 -0
  9. package/bin/workspace/volter-login.mjs +185 -0
  10. package/bin/workspace.mjs +726 -0
  11. package/browser-attach.mjs +90 -0
  12. package/client.mjs +212 -0
  13. package/connector/effects.mjs +95 -0
  14. package/connector/index.d.ts +69 -0
  15. package/connector/index.mjs +174 -0
  16. package/connector/native-continuation.d.ts +8 -0
  17. package/connector/native-continuation.mjs +50 -0
  18. package/connector/native-effects.mjs +164 -0
  19. package/context.mjs +34 -0
  20. package/contracts/adapter-map.json +16 -0
  21. package/contracts/index.d.ts +299 -0
  22. package/contracts/index.mjs +37 -0
  23. package/execution.mjs +206 -0
  24. package/fleet.mjs +78 -0
  25. package/index.mjs +12 -0
  26. package/machine-door.mjs +36 -0
  27. package/machine.mjs +1398 -0
  28. package/package.json +80 -0
  29. package/porthole-hook.cjs +137 -0
  30. package/remote/job-exec.py +146 -0
  31. package/remote/wire.py +23 -0
  32. package/server/arc2-transactions.mjs +125 -0
  33. package/server/attachment-relay.mjs +149 -0
  34. package/server/catalog.mjs +7 -0
  35. package/server/continuations.mjs +56 -0
  36. package/server/correlation.d.ts +4 -0
  37. package/server/correlation.mjs +17 -0
  38. package/server/effects-transactions.mjs +119 -0
  39. package/server/effects.mjs +97 -0
  40. package/server/event-routes.mjs +208 -0
  41. package/server/exports.mjs +46 -0
  42. package/server/harness-routes.mjs +248 -0
  43. package/server/index.d.ts +31 -0
  44. package/server/index.mjs +52 -0
  45. package/server/ingest.mjs +24 -0
  46. package/server/node-upgrade.mjs +39 -0
  47. package/server/otlp/collector-config.d.ts +2 -0
  48. package/server/otlp/collector-config.mjs +26 -0
  49. package/server/otlp/decoders/index.d.ts +63 -0
  50. package/server/otlp/decoders/index.mjs +311 -0
  51. package/server/otlp/index.d.ts +4 -0
  52. package/server/otlp/index.mjs +4 -0
  53. package/server/otlp/projection.d.ts +5 -0
  54. package/server/otlp/projection.mjs +17 -0
  55. package/server/otlp/receiver.d.ts +3 -0
  56. package/server/otlp/receiver.mjs +36 -0
  57. package/server/otlp/schema/google/protobuf/any.proto +162 -0
  58. package/server/otlp/schema/google/rpc/status.proto +48 -0
  59. package/server/otlp/schema/manifest.json +22 -0
  60. package/server/otlp/schema/opentelemetry/proto/collector/logs/v1/logs_service.proto +77 -0
  61. package/server/otlp/schema/opentelemetry/proto/collector/metrics/v1/metrics_service.proto +77 -0
  62. package/server/otlp/schema/opentelemetry/proto/collector/trace/v1/trace_service.proto +77 -0
  63. package/server/otlp/schema/opentelemetry/proto/common/v1/common.proto +154 -0
  64. package/server/otlp/schema/opentelemetry/proto/logs/v1/logs.proto +226 -0
  65. package/server/otlp/schema/opentelemetry/proto/metrics/v1/metrics.proto +735 -0
  66. package/server/otlp/schema/opentelemetry/proto/resource/v1/resource.proto +45 -0
  67. package/server/otlp/schema/opentelemetry/proto/trace/v1/trace.proto +359 -0
  68. package/server/otlp/transport.d.ts +10 -0
  69. package/server/otlp/transport.mjs +424 -0
  70. package/server/otlp/wire-codec.mjs +153 -0
  71. package/server/store-core.mjs +139 -0
  72. package/server/store-worker.mjs +23 -0
  73. package/worker/fetch-adapter.mjs +49 -0
  74. package/worker/index.mjs +127 -0
  75. package/worker/websocket-duplex.mjs +29 -0
  76. package/worker/wrangler.toml +38 -0
  77. package/workspace-client.d.ts +75 -0
  78. package/workspace-client.mjs +171 -0
  79. package/workspace-move.mjs +38 -0
package/bin/teams.mjs ADDED
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ // The teams CLI. `supercode teams …` spawns this; it is also runnable directly
3
+ // as `supercode-teams …`.
4
+ //
5
+ // Machine verbs act on this OS user's machine daemon through its local socket,
6
+ // or, with `--machine NAME`, on an enrolled machine through the selected Teams
7
+ // context's server:
8
+ //
9
+ // machine start [--home dir] [--tmux-socket name] [--verbose] run the daemon (local door only)
10
+ // machine status the running daemon, if any
11
+ // describe the host-key-signed statement
12
+ // panes ls | capture <pane> | close <pane>
13
+ // input <pane> <text> [--no-enter]
14
+ // suspend <pane> [--interrupt] | resume <pane> [--input text] refuse/allow remote input on a pane
15
+ // files ls|get|put|stat|search <path> [--out f] [--in f] [--query q]
16
+ // ports ls | resolve <pane> port <pane>/<n> [--listen host:port]
17
+ // browser <profile> [--listen host:port] a browser-profile served here as a local CDP endpoint
18
+ // browser --list the machine's browser profiles (view on browser-profile:*)
19
+ // title set <pane> <text> | title ls
20
+ // attention ls | rank | request <pane> [--urgency now|soon|whenever] [--reason text] | clear <pane>
21
+ // watch [--interval ms] [--for ms]
22
+ // rpc <harness.v1 method> [json-params] operators: one harness.v1 request
23
+ // log verify
24
+ //
25
+ // Every other verb (server, login, context, connect, disconnect, status,
26
+ // members, groups, sources, sync, machines, sessions) is the Teams workspace
27
+ // CLI in ./workspace.mjs.
28
+ import { readFileSync, writeFileSync } from 'node:fs';
29
+ import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
30
+ import { TeamsMachine } from '../machine.mjs';
31
+ import { harnessTransport, nodeRpc, forwardPort, watchMachine } from '../client.mjs';
32
+ import { attachBrowserProfile, listBrowserProfiles } from '../browser-attach.mjs';
33
+ import { openMachine } from '../machine-door.mjs';
34
+ import { moveWorkspace } from '../workspace-move.mjs';
35
+ import { teamsHome, paths, readJson, AccountLog, parseHostPort } from '@volter/teams/home';
36
+ import { NODE_PROTOCOL } from '@volter/teams/channel';
37
+
38
+ const MACHINE_VERBS = new Set(['machine', 'describe', 'panes', 'input', 'suspend', 'resume', 'files', 'ports', 'port', 'browser', 'stream', 'jobs', 'workspace', 'title', 'attention', 'watch', 'rpc', 'log', 'mail']);
39
+ // Every flag a machine verb reads; any other is refused rather than silently ignored.
40
+ const KNOWN_FLAGS = new Set(['home', 'machine', 'context', 'floor', 'supercode', 'tmux-socket', 'verbose', 'lines', 'input', 'name', 'for', 'urgency', 'reason', 'interrupt', 'query', 'out', 'in', 'interval', 'no-enter', 'when-composer-empty', 'listen', 'list', 'from', 'to', 'path', 'to-path']);
41
+
42
+ const argv = process.argv.slice(2);
43
+ const flags = {};
44
+ const positional = [];
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const a = argv[i];
47
+ if (a === '--') { positional.push(...argv.slice(i + 1)); break; }
48
+ if (a.startsWith('--')) {
49
+ const eq = a.indexOf('=');
50
+ if (eq > 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; } // --args=--norc
51
+ const key = a.slice(2);
52
+ const next = argv[i + 1];
53
+ if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i += 1; } else flags[key] = true;
54
+ } else positional.push(a);
55
+ }
56
+ const [verb, sub, ...rest] = positional;
57
+
58
+ const MACHINE_HELP = 'machine verbs: machine start|status, describe, panes, input, suspend, resume, files, ports, port, stream, jobs, workspace, title, attention, watch, rpc, log, browser (add --machine NAME for an enrolled machine)';
59
+
60
+ if (verb === undefined || verb === 'help') {
61
+ process.stdout.write(`supercode teams: ${MACHINE_HELP}\n`);
62
+ const { workspaceMain } = await import('./workspace.mjs');
63
+ await workspaceMain(['--help']);
64
+ process.exit(0);
65
+ }
66
+ if (!MACHINE_VERBS.has(verb)) {
67
+ const { workspaceMain } = await import('./workspace.mjs');
68
+ await workspaceMain(argv).catch((error) => {
69
+ process.stderr.write(JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } }) + '\n');
70
+ process.exit(error.exitCode ?? ({ invalid_request: 2, ambiguous_reference: 2, authentication_required: 3, not_found: 4, forbidden: 4, unsupported: 5, machine_offline: 5, controller_busy: 6, conflict: 6, resync_required: 6, revision_mismatch: 6, idempotency_conflict: 6, batch_gap: 6, capture_conflict: 6, source_paused: 6 }[error.code] ?? 1));
71
+ });
72
+ process.exit(0);
73
+ }
74
+
75
+ const home = flags.home ? String(flags.home) : teamsHome();
76
+ const p = paths(home);
77
+ const out = (value) => process.stdout.write((typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + '\n');
78
+ const die = (message, code = 2) => { process.stderr.write(`supercode teams: ${message}\n`); process.exit(code); };
79
+ for (const flag of Object.keys(flags)) if (!KNOWN_FLAGS.has(flag)) die(`unknown flag: --${flag}`);
80
+
81
+ async function machineChannel() {
82
+ return openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home });
83
+ }
84
+
85
+ async function withNode(fn) {
86
+ const channel = await machineChannel();
87
+ try {
88
+ const rpc = await nodeRpc(channel);
89
+ return await fn(rpc, channel);
90
+ } finally {
91
+ channel.close();
92
+ }
93
+ }
94
+
95
+ async function main() {
96
+ switch (verb) {
97
+ case 'machine': {
98
+ if (sub === 'start') {
99
+ const machine = new TeamsMachine({
100
+ home,
101
+ floor: flags.floor ? Number(flags.floor) : null,
102
+ supercode: flags.supercode ?? 'supercode',
103
+ // A named tmux server keeps a daemon's panes off the default socket.
104
+ ...(flags['tmux-socket'] ? { tmuxSocket: String(flags['tmux-socket']) } : {}),
105
+ });
106
+ // --verbose narrates every door with its latency, so a slow machine is diagnosed from its own log.
107
+ machine.on('door', (d) => { if (flags.verbose) process.stderr.write(`door ${d.method} ${d.outcome} ${d.ms}ms${d.detail ? ` ${d.detail}` : ''}\n`); });
108
+ machine.on('serve-stderr', (t) => process.stderr.write(t));
109
+ machine.on('serve-exit', (e) => process.stderr.write(`harness serve exited: ${JSON.stringify(e)}\n`));
110
+ machine.on('unrouted-notification', (m) => { if (flags.verbose) process.stderr.write(`unrouted: ${m.method}\n`); });
111
+ const socket = await machine.start();
112
+ out({ id: machine.id, socket, home, protocol: NODE_PROTOCOL, floor: machine.floor });
113
+ const stop = async () => { await machine.stop(); process.exit(0); };
114
+ process.on('SIGINT', stop);
115
+ process.on('SIGTERM', stop);
116
+ return;
117
+ }
118
+ if (sub === 'status') { out(readJson(p.machine, { running: false })); return; }
119
+ die('machine start|status');
120
+ }
121
+ case 'describe':
122
+ return withNode(async (rpc) => out(await rpc.call('describe')));
123
+ case 'panes': {
124
+ if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('panes.list')));
125
+ if (sub === 'capture') return withNode(async (rpc) => out((await rpc.call('panes.capture', { pane: rest[0], lines: Number(flags.lines ?? 200) })).text));
126
+ if (sub === 'close') return withNode(async (rpc) => out(await rpc.call('panes.close', { pane: rest[0] })));
127
+ die('panes ls|capture|close');
128
+ }
129
+ case 'title': {
130
+ if (sub === 'set') return withNode(async (rpc) => out(await rpc.call('title.set', { target: `pane:${rest[0]}`, title: rest.slice(1).join(' ') || null })));
131
+ if (sub === 'ls' || sub === undefined) return withNode(async (rpc) => out(await rpc.call('title.list')));
132
+ die('title set <pane> <text> | title ls');
133
+ }
134
+ case 'attention': {
135
+ // The machine computes the label; this prints it. Sorted by rank so the most
136
+ // urgent pane is the first line, which is the only ordering a person
137
+ // reading a fleet list ever wants.
138
+ if (sub === 'ls' || sub === undefined) {
139
+ return withNode(async (rpc) => {
140
+ const rows = await rpc.call('attention.snapshot');
141
+ out([...rows].sort((a, b) => a.rank - b.rank));
142
+ });
143
+ }
144
+ if (sub === 'rank') return withNode(async (rpc) => out(await rpc.call('attention.rank')));
145
+ if (sub === 'request') return withNode(async (rpc) => out(await rpc.call('attention.request', { pane: rest[0], urgency: flags.urgency ? String(flags.urgency) : undefined, reason: flags.reason ? String(flags.reason) : null })));
146
+ if (sub === 'clear') return withNode(async (rpc) => out(await rpc.call('attention.clear', { pane: rest[0] })));
147
+ die('attention ls | rank | request <pane> [--urgency now|soon|whenever] [--reason text] | clear <pane>');
148
+ }
149
+ case 'suspend':
150
+ return withNode(async (rpc) => out(await rpc.call('panes.suspend', { pane: sub, interrupt: Boolean(flags.interrupt) })));
151
+ case 'resume':
152
+ return withNode(async (rpc) => out(await rpc.call('panes.resume', { pane: sub, input: flags.input ? String(flags.input) : null })));
153
+ case 'files': {
154
+ const path = rest[0];
155
+ if (sub === 'ls') return withNode(async (rpc) => out(await rpc.call('files.list', { path })));
156
+ if (sub === 'stat') return withNode(async (rpc) => out(await rpc.call('files.stat', { path })));
157
+ if (sub === 'search') return withNode(async (rpc) => out(await rpc.call('files.search', { path, query: flags.query ?? '' })));
158
+ if (sub === 'get') return withNode(async (rpc) => {
159
+ const result = await rpc.call('files.read', { path });
160
+ const bytes = Buffer.from(result.bytes, 'base64');
161
+ if (flags.out) writeFileSync(flags.out, bytes); else process.stdout.write(bytes);
162
+ });
163
+ if (sub === 'put') return withNode(async (rpc) => {
164
+ const bytes = flags.in ? readFileSync(flags.in) : readFileSync(0);
165
+ out(await rpc.call('files.write', { path, bytes: bytes.toString('base64'), truncate: true }));
166
+ });
167
+ die('files ls|stat|search|get|put <path>');
168
+ }
169
+ case 'ports': {
170
+ if (sub === 'ls' || sub === undefined) return withNode(async (rpc) => out(await rpc.call('ports.list')));
171
+ if (sub === 'resolve') return withNode(async (rpc) => out(await rpc.call('ports.resolve', { pane: rest[0] })));
172
+ die('ports ls|resolve <pane>');
173
+ }
174
+ case 'watch': {
175
+ // `watch [--interval ms]`: the machine's live state as JSON lines — one snapshot, then deltas — until ctrl-c.
176
+ const channel = await machineChannel();
177
+ const w = await watchMachine(channel, { intervalMs: Number(flags.interval ?? 1000) });
178
+ w.on('snapshot', (s) => out(JSON.stringify(s)));
179
+ w.on('delta', (d) => out(JSON.stringify(d)));
180
+ w.on('error-message', (m) => process.stderr.write(`[watch: ${m}]\n`));
181
+ w.on('close', (reason) => { process.stderr.write(`[watch ended${reason ? `: ${reason}` : ''}]\n`); channel.close(); process.exit(0); });
182
+ if (typeof flags.for === 'string') setTimeout(() => { w.close(); }, Number(flags.for));
183
+ return;
184
+ }
185
+ case 'input':
186
+ // `input <pane> <text>`: one delivery through the host's own sendInput; gated like control.
187
+ // `--when-composer-empty`: a user's own turn, typed only into an empty composer
188
+ // (answers `delivered: false` with the reason when it holds a draft).
189
+ if (flags['when-composer-empty']) return withNode(async (rpc) => out(await rpc.call('panes.turn', { pane: sub, text: rest.join(' ') })));
190
+ return withNode(async (rpc) => out(await rpc.call('panes.input', { pane: sub, text: rest.join(' '), submit: !flags['no-enter'] })));
191
+ case 'port': {
192
+ // `port <pane>/<n> [--listen host:port]`: a machine port forwarded here. Stays up until ctrl-c.
193
+ const spec = String(sub ?? '');
194
+ const slash = spec.lastIndexOf('/');
195
+ const pane = slash > 0 ? spec.slice(0, slash) : null;
196
+ const portNumber = Number(slash >= 0 ? spec.slice(slash + 1) : spec);
197
+ if (!Number.isInteger(portNumber)) die('port <pane>/<n> [--listen host:port]');
198
+ const channel = await machineChannel();
199
+ const fwd = await forwardPort(channel, { pane, port: portNumber, listen: parseHostPort(flags.listen) });
200
+ out({ local: fwd.address, remote: `${pane ?? '*'}/${portNumber}` });
201
+ const stop = async () => { await fwd.close(); channel.close(); process.exit(0); };
202
+ process.on('SIGINT', stop);
203
+ process.on('SIGTERM', stop);
204
+ return;
205
+ }
206
+ case 'browser': {
207
+ // `browser <profile> [--listen host:port]`: a machine's browser profile as a local CDP
208
+ // endpoint (connectOverCDP to the printed address). Stays up until ctrl-c.
209
+ if (flags.list) {
210
+ const channel = await machineChannel();
211
+ try { out(await listBrowserProfiles(channel)); } finally { channel.close(); }
212
+ return;
213
+ }
214
+ if (!sub) die('browser <profile> [--listen host:port] [--machine NAME] | browser --list [--machine NAME]');
215
+ const attached = await attachBrowserProfile(() => machineChannel(), { profile: String(sub), listen: parseHostPort(flags.listen) });
216
+ out({ cdp: attached.address, target: `browser-profile:${sub}` });
217
+ const stop = async () => { await attached.close(); process.exit(0); };
218
+ process.on('SIGINT', stop);
219
+ process.on('SIGTERM', stop);
220
+ return;
221
+ }
222
+ case 'stream': {
223
+ // `stream exec|sftp [--machine NAME]`: this process's stdin and stdout carried to one of the
224
+ // machine's execution streams, the transport a pilot's exec client and SSHFS (passive) use.
225
+ if (sub !== 'exec' && sub !== 'sftp') die('stream exec|sftp [--machine NAME]');
226
+ const channel = await machineChannel();
227
+ let stream;
228
+ try { stream = await channel.open(sub, {}); } catch (error) { die(error.message, 1); }
229
+ stream.on('data', (chunk) => process.stdout.write(Buffer.from(chunk, 'base64')));
230
+ stream.on('close', () => { channel.close(); process.stdout.end(() => process.exit(0)); });
231
+ process.stdin.on('data', (bytes) => { if (!stream.closed) stream.write(bytes.toString('base64')); });
232
+ process.stdin.on('end', () => stream.close('input ended'));
233
+ return;
234
+ }
235
+ case 'mail': {
236
+ // `mail --machine NAME`: one `supercode message` request (JSON on stdin) to that
237
+ // machine's mail door, and its answer (one JSON line) on stdout.
238
+ const chunks = [];
239
+ for await (const chunk of process.stdin) chunks.push(chunk);
240
+ let request;
241
+ try { request = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { die('mail reads one JSON request on stdin'); }
242
+ const channel = await machineChannel();
243
+ let stream;
244
+ try { stream = await channel.open('mail', { request }); } catch (error) { die(error.message, 1); }
245
+ let answered = false;
246
+ stream.on('data', (chunk) => { answered = true; process.stdout.write(`${chunk}\n`); });
247
+ stream.on('close', (reason) => {
248
+ channel.close();
249
+ if (!answered) process.stdout.write(`${JSON.stringify({ outcome: 'failed', detail: String(reason ?? 'the mail door closed without answering') })}\n`);
250
+ process.exit(0);
251
+ });
252
+ return;
253
+ }
254
+ case 'workspace': {
255
+ // `workspace move --from A --to B --path P [--to-path Q]`: the project leaves A and arrives
256
+ // at B, published there only if every byte arrived (sha256 on commit) and neither machine
257
+ // runs an exec job. A is left as it was; B's path must not exist.
258
+ if (sub !== 'move' || !flags.from || !flags.to || !flags.path) die('workspace move --from MACHINE --to MACHINE --path P [--to-path Q]');
259
+ try {
260
+ out(await moveWorkspace({ from: String(flags.from), to: String(flags.to), path: String(flags.path), toPath: String(flags['to-path'] ?? flags.path), context: flags.context ? String(flags.context) : null, home }));
261
+ } catch (error) { die(error.message, error.exitCode ?? 1); }
262
+ return;
263
+ }
264
+ case 'jobs':
265
+ // `jobs [--machine NAME]`: the execution jobs the machine's daemon owns now.
266
+ return withNode(async (rpc) => out(await rpc.call('jobs.list', {})));
267
+ case 'rpc': {
268
+ const channel = await machineChannel();
269
+ const client = new SupercodeHarnessClient({ transport: harnessTransport(channel) });
270
+ try {
271
+ await client.start?.();
272
+ const params = rest[0] ? JSON.parse(rest[0]) : {};
273
+ const method = sub.startsWith('harness.v1.') ? sub : `harness.v1.${sub}`;
274
+ out(await client.request(method, params));
275
+ } finally {
276
+ await client.close?.();
277
+ channel.close();
278
+ }
279
+ return;
280
+ }
281
+ case 'log': {
282
+ if (sub !== 'verify') die('log verify');
283
+ out(AccountLog.verify(p.log));
284
+ return;
285
+ }
286
+ default:
287
+ die(MACHINE_HELP);
288
+ }
289
+ }
290
+
291
+ main().catch((error) => die(error.message ?? String(error), error.code === 'machine_offline' ? 5 : 1));
@@ -0,0 +1,66 @@
1
+ const BOOLEANS = new Set([
2
+ 'json', 'help', 'live', 'saved', 'watch', 'paginate', 'token-stdin',
3
+ 'invite-stdin', 'include-children', 'rotate', 'install', 'force',
4
+ 'take-control', 'dry-run', 'volter', 'foreground',
5
+ ]);
6
+
7
+ const VALUES = new Set([
8
+ 'context', 'server', 'team', 'credential-ref', 'data-dir', 'listen',
9
+ 'public-origin', 'init-team', 'as', 'user', 'repo', 'harness', 'machine',
10
+ 'source', 'cursor', 'limit', 'tail', 'before', 'after', 'revision', 'role',
11
+ 'out', 'ttl', 'type', 'name', 'visibility', 'backfill', 'format', 'to',
12
+ 'grant', 'binding', 'caps', 'fidelity', 'cwd', 'root', 'workspace-from', 'since', 'until', 'session',
13
+ 'on', 'timeout', 'plan', 'tmux-socket', 'volter-issuer', 'delegated-issuer', 'workplace', 'organization', 'key', 'room',
14
+ ]);
15
+
16
+ export function parseArgs(argv) {
17
+ const flags = {};
18
+ const positionals = [];
19
+ for (let index = 0; index < argv.length; index += 1) {
20
+ const argument = argv[index];
21
+ if (argument === '--') {
22
+ positionals.push(...argv.slice(index + 1));
23
+ break;
24
+ }
25
+ if (!argument.startsWith('--')) {
26
+ positionals.push(argument);
27
+ continue;
28
+ }
29
+ const equals = argument.indexOf('=');
30
+ const raw = argument.slice(2, equals < 0 ? undefined : equals);
31
+ const key = raw.replaceAll('-', '_');
32
+ if (!BOOLEANS.has(raw) && !VALUES.has(raw)) usage(`unknown option --${raw}`);
33
+ if (Object.hasOwn(flags, key)) usage(`duplicate option --${raw}`);
34
+ if (BOOLEANS.has(raw)) {
35
+ if (equals >= 0) usage(`--${raw} does not take a value`);
36
+ flags[key] = true;
37
+ continue;
38
+ }
39
+ const value = equals >= 0 ? argument.slice(equals + 1) : argv[++index];
40
+ if (value === undefined || value.startsWith('--')) usage(`--${raw} requires a value`);
41
+ flags[key] = value;
42
+ }
43
+ return { flags, positionals };
44
+ }
45
+
46
+ export function usage(message) {
47
+ throw Object.assign(new Error(message), { code: 'invalid_request', exitCode: 2 });
48
+ }
49
+
50
+ export function requireArgs(actual, min, max, syntax) {
51
+ if (actual.length < min || actual.length > max) usage(`usage: ${syntax}`);
52
+ }
53
+
54
+ export function positiveInteger(value, fallback, name) {
55
+ if (value === undefined) return fallback;
56
+ if (!/^[1-9]\d*$/.test(value)) usage(`--${name} must be a positive integer`);
57
+ return Number(value);
58
+ }
59
+
60
+ export function duration(value, fallback, { allowWords = false } = {}) {
61
+ if (value === undefined) return fallback;
62
+ if (allowWords && ['all', 'none'].includes(value)) return value;
63
+ const match = /^([1-9]\d*)([smhd])$/.exec(value);
64
+ if (!match) usage('duration must be a positive integer followed by s, m, h, or d');
65
+ return Number(match[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2]];
66
+ }
@@ -0,0 +1,110 @@
1
+ import { chmodSync, closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
4
+ import { usage } from './args.mjs';
5
+
6
+ export function assertWritable(path, { force = false } = {}) {
7
+ if (!path) usage('--out is required');
8
+ if (existsSync(path) && !force) usage(`refusing to overwrite existing file ${path}; pass --force`);
9
+ }
10
+
11
+ export function writeProtected(path, value, { force = false } = {}) {
12
+ assertWritable(path, { force });
13
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
14
+ const temporary = `${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;
15
+ const descriptor = openSync(temporary, 'wx', 0o600);
16
+ try { writeFileSync(descriptor, JSON.stringify(value, null, 2) + '\n'); } finally { closeSync(descriptor); }
17
+ try {
18
+ if (force) renameSync(temporary, path);
19
+ else { linkSync(temporary, path); unlinkSync(temporary); }
20
+ } catch (error) { try { unlinkSync(temporary); } catch {} if (error.code === 'EEXIST') usage(`refusing to overwrite existing file ${path}; pass --force`); throw error; }
21
+ chmodSync(path, 0o600);
22
+ }
23
+
24
+ export function reserveProtected(path, { force = false } = {}) {
25
+ if (!path) usage('--out is required');
26
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
27
+ if (!force) {
28
+ let descriptor;
29
+ try { descriptor = openSync(path, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST') usage(`refusing to overwrite existing file ${path}; pass --force`); throw error; }
30
+ let settled = false;
31
+ return {
32
+ write(value) { if (settled) throw new Error('output reservation is already settled'); writeFileSync(descriptor, JSON.stringify(value, null, 2) + '\n'); closeSync(descriptor); descriptor = null; chmodSync(path, 0o600); settled = true; },
33
+ writeBytes(value) { if (settled) throw new Error('output reservation is already settled'); writeFileSync(descriptor, value); closeSync(descriptor); descriptor = null; chmodSync(path, 0o600); settled = true; },
34
+ abort() { if (settled) return; if (descriptor !== null) closeSync(descriptor); try { unlinkSync(path); } catch {} settled = true; },
35
+ };
36
+ }
37
+ const temporary = `${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`, descriptor = openSync(temporary, 'wx', 0o600);
38
+ let settled = false;
39
+ return {
40
+ write(value) { if (settled) throw new Error('output reservation is already settled'); writeFileSync(descriptor, JSON.stringify(value, null, 2) + '\n'); closeSync(descriptor); renameSync(temporary, path); chmodSync(path, 0o600); settled = true; },
41
+ writeBytes(value) { if (settled) throw new Error('output reservation is already settled'); writeFileSync(descriptor, value); closeSync(descriptor); renameSync(temporary, path); chmodSync(path, 0o600); settled = true; },
42
+ abort() { if (settled) return; closeSync(descriptor); try { unlinkSync(temporary); } catch {} settled = true; },
43
+ };
44
+ }
45
+
46
+ export function reserveArtifactDirectory(path, { force = false } = {}) {
47
+ if (!path) usage('--out is required');
48
+ path = resolve(path);
49
+ const parent = dirname(path), lock = `${path}.export-lock`, stage = `${path}.stage.${process.pid}.${randomBytes(12).toString('hex')}`;
50
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
51
+ try { mkdirSync(lock, { mode: 0o700 }); } catch (error) { if (error.code === 'EEXIST') usage(`export destination ${path} is reserved by another writer`); throw error; }
52
+ let destinationIdentity = null;
53
+ try {
54
+ if (existsSync(path)) {
55
+ if (!force) usage(`refusing to overwrite existing directory ${path}; pass --force`);
56
+ const status = lstatSync(path);
57
+ if (!status.isDirectory() || status.isSymbolicLink()) usage(`export destination ${path} must be a real directory`);
58
+ destinationIdentity = { dev: status.dev, ino: status.ino };
59
+ }
60
+ mkdirSync(stage, { mode: 0o700 });
61
+ } catch (error) { rmSync(lock, { recursive: true, force: true }); throw error; }
62
+ let settled = false;
63
+ const safe = name => {
64
+ if (typeof name !== 'string' || !name || isAbsolute(name)) usage('export contains an unsafe relative filename');
65
+ const clean = normalize(name); if (clean === '..' || clean.startsWith(`..${sep}`) || clean.includes(`.${sep}..${sep}`)) usage('export contains an unsafe relative filename');
66
+ const target = resolve(stage, clean); if (relative(stage, target).startsWith('..')) usage('export filename escapes its destination');
67
+ return { clean, target };
68
+ };
69
+ return {
70
+ path,
71
+ write(name, bytes) { const { target } = safe(name); mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); const fd = openSync(target, 'wx', 0o600); try { writeFileSync(fd, bytes); } finally { closeSync(fd); } },
72
+ publish() {
73
+ if (settled) throw new Error('artifact directory reservation is already settled');
74
+ const current = existsSync(path) ? lstatSync(path) : null;
75
+ if (!destinationIdentity) {
76
+ if (current) usage(`refusing to overwrite existing directory ${path}; pass --force`);
77
+ renameSync(stage, path);
78
+ } else {
79
+ if (!current || !current.isDirectory() || current.isSymbolicLink() || current.dev !== destinationIdentity.dev || current.ino !== destinationIdentity.ino) usage(`export destination ${path} changed while the export was staged`);
80
+ const stagedFiles = [];
81
+ const inspectTree = (source, relativeRoot = '') => {
82
+ for (const entry of readdirSync(source, { withFileTypes: true })) {
83
+ const relativeName = join(relativeRoot, entry.name), target = join(path, relativeName);
84
+ let cursor = path;
85
+ for (const segment of relativeName.split(sep).slice(0, -1)) {
86
+ cursor = join(cursor, segment);
87
+ if (existsSync(cursor)) {
88
+ const status = lstatSync(cursor);
89
+ if (!status.isDirectory() || status.isSymbolicLink()) usage(`export path ${cursor} is not a real directory`);
90
+ }
91
+ }
92
+ if (entry.isDirectory()) inspectTree(join(source, entry.name), relativeName);
93
+ else {
94
+ if (existsSync(target) && lstatSync(target).isSymbolicLink()) usage(`export path ${target} is a symlink`);
95
+ stagedFiles.push({ from: join(source, entry.name), to: target });
96
+ }
97
+ }
98
+ };
99
+ inspectTree(stage);
100
+ for (const { to } of stagedFiles) mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
101
+ const afterPreflight = lstatSync(path);
102
+ if (!afterPreflight.isDirectory() || afterPreflight.isSymbolicLink() || afterPreflight.dev !== destinationIdentity.dev || afterPreflight.ino !== destinationIdentity.ino) usage(`export destination ${path} changed while the export was staged`);
103
+ for (const { from, to } of stagedFiles) { renameSync(from, to); chmodSync(to, 0o600); }
104
+ rmSync(stage, { recursive: true, force: true });
105
+ }
106
+ rmSync(lock, { recursive: true, force: true }); settled = true;
107
+ },
108
+ abort() { if (settled) return; rmSync(stage, { recursive: true, force: true }); rmSync(lock, { recursive: true, force: true }); settled = true; },
109
+ };
110
+ }
@@ -0,0 +1,113 @@
1
+ import { createConnection, createServer } from 'node:net';
2
+ import { mkdirSync, renameSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { shortSocketPath, ensureSocketDirectory } from '@volter/teams/home';
6
+ import { execFileSync } from 'node:child_process';
7
+
8
+ const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
9
+ const processIdentity = pid => {
10
+ try { return execFileSync('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 1_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null; }
11
+ catch { return null; }
12
+ };
13
+ const readOwner = lock => {
14
+ try { return JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')); }
15
+ catch { return null; }
16
+ };
17
+ const sameOwner = owner => owner?.pid > 0 && owner.start === processIdentity(owner.pid);
18
+
19
+ export async function activeSocket(path) {
20
+ return new Promise(resolve => {
21
+ const socket = createConnection(path);
22
+ let settled = false;
23
+ const finish = value => { if (settled) return; settled = true; socket.destroy(); resolve(value); };
24
+ socket.setTimeout(1_000, () => finish(false));
25
+ socket.once('connect', () => socket.end('probe\n'));
26
+ socket.once('data', () => finish(true));
27
+ socket.once('end', () => finish(true));
28
+ socket.once('error', () => finish(false));
29
+ });
30
+ }
31
+
32
+ export async function acquireLifecycle(directory, onStop) {
33
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
34
+ const lock = join(directory, 'lifecycle.lock'), socketPath = shortSocketPath(directory, 'control.sock');
35
+ ensureSocketDirectory(socketPath);
36
+ const token = randomBytes(32).toString('base64url');
37
+ const owner = { version: 1, token, pid: process.pid, start: processIdentity(process.pid), created_at: Date.now() };
38
+ for (;;) {
39
+ try {
40
+ mkdirSync(lock, { mode: 0o700 });
41
+ writeFileSync(join(lock, 'owner.json'), `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: 'wx' });
42
+ break;
43
+ } catch (error) {
44
+ if (error.code !== 'EEXIST') throw error;
45
+ const current = readOwner(lock);
46
+ // A live exact process identity owns the lock even before its control
47
+ // socket is bound. A just-created incomplete record receives the same
48
+ // startup protection rather than being stolen by a concurrent caller.
49
+ if (sameOwner(current) || !current || Date.now() - Number(current.created_at ?? 0) < 10_000) {
50
+ throw Object.assign(new Error('connector is already running or starting'), { code: 'conflict', exitCode: 6 });
51
+ }
52
+ const stale = `${lock}.stale.${randomBytes(8).toString('hex')}`;
53
+ try {
54
+ // rename is the atomic claim. Re-read the moved inode before deleting:
55
+ // another recoverer may already have replaced `lock` with its live
56
+ // ownership record after our stale read. In that case restore it and
57
+ // refuse instead of stealing the successor.
58
+ renameSync(lock, stale);
59
+ if (readOwner(stale)?.token !== current.token) {
60
+ try { renameSync(stale, lock); } catch {}
61
+ throw Object.assign(new Error('connector ownership changed during stale recovery'), { code: 'conflict', exitCode: 6 });
62
+ }
63
+ rmSync(stale, { recursive: true, force: true });
64
+ }
65
+ catch (renameError) { if (renameError.code !== 'ENOENT') throw renameError; }
66
+ }
67
+ }
68
+ try { unlinkSync(socketPath); } catch (error) { if (error.code !== 'ENOENT') throw error; }
69
+ const server = createServer(connection => {
70
+ let body = ''; connection.setEncoding('utf8');
71
+ connection.on('error', () => {});
72
+ connection.on('data', chunk => { body += chunk; if (body.length > 1024) connection.destroy(); });
73
+ connection.on('end', () => {
74
+ if (body.trim() !== token) { connection.end('forbidden\n'); return; }
75
+ connection.end('ok\n', () => { void onStop(); });
76
+ });
77
+ });
78
+ try {
79
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
80
+ } catch (error) {
81
+ if (readOwner(lock)?.token === token) rmSync(lock, { recursive: true, force: true });
82
+ throw error;
83
+ }
84
+ return {
85
+ token,
86
+ socketPath,
87
+ close: async () => {
88
+ await new Promise(resolve => server.close(resolve));
89
+ if (readOwner(lock)?.token !== token) return;
90
+ try { unlinkSync(socketPath); } catch {}
91
+ rmSync(lock, { recursive: true, force: true });
92
+ },
93
+ };
94
+ }
95
+
96
+ export async function requestStop(socketPath, token) {
97
+ for (let attempt = 0; attempt < 5; attempt += 1) {
98
+ try {
99
+ const reply = await new Promise((resolve, reject) => {
100
+ const socket = createConnection(socketPath); let text = '', settled = false;
101
+ const finish = (error, value) => { if (settled) return; settled = true; socket.destroy(); error ? reject(error) : resolve(value); };
102
+ socket.setTimeout(500, () => finish(new Error('connector control request timed out')));
103
+ socket.setEncoding('utf8');
104
+ socket.once('connect', () => socket.end(`${token}\n`));
105
+ socket.on('data', chunk => { text += chunk; if (text.length > 1024) finish(new Error('connector control reply is too large')); });
106
+ socket.once('end', () => finish(null, text));
107
+ socket.once('error', error => finish(error));
108
+ });
109
+ if (reply.trim() !== 'ok') throw new Error('connector control token was rejected');
110
+ return;
111
+ } catch (error) { if (attempt === 4) throw error; await wait(50); }
112
+ }
113
+ }
@@ -0,0 +1,42 @@
1
+ // Signing a person in with Volter from a terminal: through this machine's one shared Volter sign-in
2
+ // (`@volter/identity`, `volter login`). When someone has already signed in on this machine (with `volter login` or
3
+ // any Volter tool), no prompt appears; otherwise the person signs in once, in the browser where there is one and by
4
+ // a device code where there is not, and the result is kept for every tool. Either way the Teams server gets its own access token, whose
5
+ // audience is that server.
6
+ import { DEFAULT_ISSUER, signIn, tokenFor } from '@volter/identity';
7
+
8
+ export const VOLTER_ISSUER = DEFAULT_ISSUER;
9
+
10
+ /**
11
+ * The Teams server's access token, or with `finish` what finish makes of it. `finish({ token, ask })` runs once the
12
+ * person is signed in: `ask(question)` puts a question in the browser tab they signed in from, and is null when there
13
+ * is none (they were signed in already, or signed in by a device code). It returns `{ result, page }`: the result is
14
+ * this function's, and `page` what the tab ends on.
15
+ */
16
+ export async function volterDeviceSignIn({ issuer = VOLTER_ISSUER, audience, say = (line) => process.stderr.write(`${line}\n`), finish = null }) {
17
+ issuer = new URL(issuer).origin;
18
+ const held = await tokenFor(audience);
19
+ if (held) return finish ? (await finish({ token: held, ask: null })).result : held;
20
+ let finished = null;
21
+ const token = async () => {
22
+ const value = await tokenFor(audience);
23
+ if (!value) throw new Error('the Volter sign-in did not keep');
24
+ return value;
25
+ };
26
+ await signIn({
27
+ issuer,
28
+ prompt: ({ url, userCode, opened }) => {
29
+ say(opened
30
+ ? `Your browser is open to sign in with Volter; finish there. (If it did not open: ${url})`
31
+ : `To sign in with Volter, open ${url}${userCode ? ` and enter the code ${userCode}` : ''}`);
32
+ say('Every Volter tool on this machine will use this sign-in.');
33
+ },
34
+ finish: async ({ ask }) => {
35
+ finished = finish ? await finish({ token: await token(), ask: ask ?? null }) : { result: await token() };
36
+ return finished.page;
37
+ },
38
+ });
39
+ // An @volter/identity before 0.4 does not run `finish`; the sign-in is kept all the same.
40
+ finished ??= finish ? await finish({ token: await token(), ask: null }) : { result: await token() };
41
+ return finished.result;
42
+ }