@volter/supercode-teams 0.2.20 → 0.2.21
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.
- package/README.md +2 -1
- package/bin/teams.mjs +18 -7
- package/bin/workspace/args.mjs +1 -1
- package/bin/workspace/lifecycle.mjs +25 -10
- package/bin/workspace.mjs +123 -4
- package/connector/index.mjs +14 -2
- package/machine.mjs +11 -3
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -29,7 +29,8 @@ The daemon owns one `supercode harness serve` and the tmux terminal host.
|
|
|
29
29
|
Two doors reach it:
|
|
30
30
|
|
|
31
31
|
- **Local**: an owner-only Unix socket, `$SUPERCODE_HOME/teams/machine.sock` (or, when that path
|
|
32
|
-
is too long for a socket address, a per-user 0700 directory under `/tmp`).
|
|
32
|
+
is too long for a socket address, a per-user 0700 directory under `/tmp`). On Windows it is a
|
|
33
|
+
named pipe, `\\.\pipe\supercode-<hash>`, which by default only its owner may open. Whoever can open it
|
|
33
34
|
is this OS user, an operator. The local CLI, the agent inside the machine and a service on the
|
|
34
35
|
same box (RH2's connector) use it.
|
|
35
36
|
- **Remote**: the Teams server. The daemon holds one outbound WebSocket link; the server forwards
|
package/bin/teams.mjs
CHANGED
|
@@ -25,8 +25,16 @@
|
|
|
25
25
|
// Every other verb (server, login, context, connect, disconnect, status,
|
|
26
26
|
// members, groups, sources, sync, machines, sessions) is the Teams workspace
|
|
27
27
|
// CLI in ./workspace.mjs.
|
|
28
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { createWriteStream, readFileSync, writeFileSync } from 'node:fs';
|
|
29
29
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
30
|
+
|
|
31
|
+
// A Windows scheduled task keeps no output (launchd and systemd name log files), so its unit names one here.
|
|
32
|
+
if (process.env.SUPERCODE_TEAMS_LOG) {
|
|
33
|
+
const log = createWriteStream(process.env.SUPERCODE_TEAMS_LOG, { flags: 'a' });
|
|
34
|
+
for (const stream of [process.stdout, process.stderr]) stream.write = log.write.bind(log);
|
|
35
|
+
// The log is this process's own; a pane or harness it starts writes to its own terminal.
|
|
36
|
+
delete process.env.SUPERCODE_TEAMS_LOG;
|
|
37
|
+
}
|
|
30
38
|
import { TeamsMachine } from '../machine.mjs';
|
|
31
39
|
import { harnessTransport, nodeRpc, forwardPort, watchMachine } from '../client.mjs';
|
|
32
40
|
import { attachBrowserProfile, listBrowserProfiles } from '../browser-attach.mjs';
|
|
@@ -63,20 +71,23 @@ if (verb === undefined || verb === 'help') {
|
|
|
63
71
|
await workspaceMain(['--help']);
|
|
64
72
|
process.exit(0);
|
|
65
73
|
}
|
|
66
|
-
|
|
74
|
+
// A workspace verb is done when its promise settles. On Windows the process then ends on its own: Node there aborts
|
|
75
|
+
// (libuv's `UV_HANDLE_CLOSING` assertion) when process.exit() follows a fetch, which every workspace verb makes.
|
|
76
|
+
const workspaceVerb = !MACHINE_VERBS.has(verb);
|
|
77
|
+
if (workspaceVerb) {
|
|
67
78
|
const { workspaceMain } = await import('./workspace.mjs');
|
|
68
|
-
await workspaceMain(argv).
|
|
79
|
+
await workspaceMain(argv).then(() => { process.exitCode = 0; }, (error) => {
|
|
69
80
|
process.stderr.write(JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } }) + '\n');
|
|
70
|
-
process.
|
|
81
|
+
process.exitCode = (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
82
|
});
|
|
72
|
-
process.exit(
|
|
83
|
+
if (process.platform !== 'win32') process.exit();
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
const home = flags.home ? String(flags.home) : teamsHome();
|
|
76
87
|
const p = paths(home);
|
|
77
88
|
const out = (value) => process.stdout.write((typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + '\n');
|
|
78
89
|
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}`);
|
|
90
|
+
if (!workspaceVerb) for (const flag of Object.keys(flags)) if (!KNOWN_FLAGS.has(flag)) die(`unknown flag: --${flag}`);
|
|
80
91
|
|
|
81
92
|
async function machineChannel() {
|
|
82
93
|
return openMachine({ machine: flags.machine ? String(flags.machine) : null, context: flags.context ? String(flags.context) : null, home });
|
|
@@ -288,4 +299,4 @@ async function main() {
|
|
|
288
299
|
}
|
|
289
300
|
}
|
|
290
301
|
|
|
291
|
-
main().catch((error) => die(error.message ?? String(error), error.code === 'machine_offline' ? 5 : 1));
|
|
302
|
+
if (!workspaceVerb) main().catch((error) => die(error.message ?? String(error), error.code === 'machine_offline' ? 5 : 1));
|
package/bin/workspace/args.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const BOOLEANS = new Set([
|
|
2
2
|
'json', 'help', 'live', 'saved', 'watch', 'paginate', 'token-stdin',
|
|
3
3
|
'invite-stdin', 'include-children', 'rotate', 'install', 'force',
|
|
4
|
-
'take-control', 'dry-run', 'volter', 'foreground',
|
|
4
|
+
'take-control', 'dry-run', 'volter', 'foreground', 'no-connect',
|
|
5
5
|
]);
|
|
6
6
|
|
|
7
7
|
const VALUES = new Set([
|
|
@@ -2,19 +2,30 @@ import { createConnection, createServer } from 'node:net';
|
|
|
2
2
|
import { mkdirSync, renameSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
|
-
import { shortSocketPath, ensureSocketDirectory } from '@volter/teams/home';
|
|
5
|
+
import { shortSocketPath, ensureSocketDirectory, isNamedPipe } from '@volter/teams/home';
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
7
|
|
|
8
8
|
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
9
|
+
// A process's start time names it exactly: a reused pid has another. Windows has no ps; its process table answers.
|
|
9
10
|
const processIdentity = pid => {
|
|
10
|
-
|
|
11
|
+
const [command, args] = process.platform === 'win32'
|
|
12
|
+
? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${Number(pid)}).StartTime.ToUniversalTime().Ticks`]]
|
|
13
|
+
: ['/bin/ps', ['-o', 'lstart=', '-p', String(pid)]];
|
|
14
|
+
try { return execFileSync(command, args, { encoding: 'utf8', timeout: process.platform === 'win32' ? 5_000 : 1_000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim() || null; }
|
|
11
15
|
catch { return null; }
|
|
12
16
|
};
|
|
13
17
|
const readOwner = lock => {
|
|
14
18
|
try { return JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')); }
|
|
15
19
|
catch { return null; }
|
|
16
20
|
};
|
|
17
|
-
|
|
21
|
+
// Without both start times (a lookup that timed out, as a cold PowerShell can) the pid's liveness decides, so a live
|
|
22
|
+
// owner is never taken for stale.
|
|
23
|
+
const sameOwner = owner => {
|
|
24
|
+
if (!(owner?.pid > 0)) return false;
|
|
25
|
+
const start = processIdentity(owner.pid);
|
|
26
|
+
if (owner.start != null && start != null) return owner.start === start;
|
|
27
|
+
try { process.kill(owner.pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
28
|
+
};
|
|
18
29
|
|
|
19
30
|
export async function activeSocket(path) {
|
|
20
31
|
return new Promise(resolve => {
|
|
@@ -65,15 +76,19 @@ export async function acquireLifecycle(directory, onStop) {
|
|
|
65
76
|
catch (renameError) { if (renameError.code !== 'ENOENT') throw renameError; }
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
|
-
try { unlinkSync(socketPath); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
79
|
+
if (!isNamedPipe(socketPath)) try { unlinkSync(socketPath); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
69
80
|
const server = createServer(connection => {
|
|
70
|
-
let body = ''; connection.setEncoding('utf8');
|
|
81
|
+
let body = '', answered = false; connection.setEncoding('utf8');
|
|
71
82
|
connection.on('error', () => {});
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
// A request is one line; it is answered at its newline, or at the end of a request without one. A Windows named
|
|
84
|
+
// pipe has no half-close, so a client there cannot end its side and still read the answer.
|
|
85
|
+
const answer = () => {
|
|
86
|
+
if (answered) return; answered = true;
|
|
74
87
|
if (body.trim() !== token) { connection.end('forbidden\n'); return; }
|
|
75
88
|
connection.end('ok\n', () => { void onStop(); });
|
|
76
|
-
}
|
|
89
|
+
};
|
|
90
|
+
connection.on('data', chunk => { body += chunk; if (body.length > 1024) connection.destroy(); else if (body.includes('\n')) answer(); });
|
|
91
|
+
connection.on('end', answer);
|
|
77
92
|
});
|
|
78
93
|
try {
|
|
79
94
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
|
|
@@ -87,7 +102,7 @@ export async function acquireLifecycle(directory, onStop) {
|
|
|
87
102
|
close: async () => {
|
|
88
103
|
await new Promise(resolve => server.close(resolve));
|
|
89
104
|
if (readOwner(lock)?.token !== token) return;
|
|
90
|
-
try { unlinkSync(socketPath); } catch {}
|
|
105
|
+
if (!isNamedPipe(socketPath)) try { unlinkSync(socketPath); } catch {}
|
|
91
106
|
rmSync(lock, { recursive: true, force: true });
|
|
92
107
|
},
|
|
93
108
|
};
|
|
@@ -101,7 +116,7 @@ export async function requestStop(socketPath, token) {
|
|
|
101
116
|
const finish = (error, value) => { if (settled) return; settled = true; socket.destroy(); error ? reject(error) : resolve(value); };
|
|
102
117
|
socket.setTimeout(500, () => finish(new Error('connector control request timed out')));
|
|
103
118
|
socket.setEncoding('utf8');
|
|
104
|
-
socket.once('connect', () => socket.end(`${token}\n`));
|
|
119
|
+
socket.once('connect', () => process.platform === 'win32' ? socket.write(`${token}\n`) : socket.end(`${token}\n`));
|
|
105
120
|
socket.on('data', chunk => { text += chunk; if (text.length > 1024) finish(new Error('connector control reply is too large')); });
|
|
106
121
|
socket.once('end', () => finish(null, text));
|
|
107
122
|
socket.once('error', error => finish(error));
|
package/bin/workspace.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
4
4
|
import { basename, join, resolve } from 'node:path';
|
|
5
5
|
import { execFileSync, spawn } from 'node:child_process';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
@@ -19,6 +19,8 @@ import { reserveArtifactDirectory, reserveProtected, writeProtected } from './wo
|
|
|
19
19
|
import { acquireLifecycle, activeSocket, requestStop } from './workspace/lifecycle.mjs';
|
|
20
20
|
import { volterDeviceSignIn } from './workspace/volter-device.mjs';
|
|
21
21
|
import { loginSummary, signInToTeams } from './workspace/volter-login.mjs';
|
|
22
|
+
import { readSharedSignIn, signOut } from '@volter/identity';
|
|
23
|
+
import { createInterface } from 'node:readline/promises';
|
|
22
24
|
|
|
23
25
|
const ERROR_EXITS = { 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 };
|
|
24
26
|
|
|
@@ -35,6 +37,7 @@ export async function workspaceMain(argv = process.argv.slice(2), env = process.
|
|
|
35
37
|
if (verb === 'context') return contextCommand({ contexts, flags, sub, rest, output });
|
|
36
38
|
if (verb === 'server') return serverCommand({ flags, sub, rest, home, output });
|
|
37
39
|
if (verb === 'login') return loginCommand({ flags, origin: sub, rest, home, output });
|
|
40
|
+
if (verb === 'auth') return authCommand({ flags, sub, rest, home, contexts, output, env, dependencies });
|
|
38
41
|
const explicitContext = flags.context ?? env.SUPERCODE_CONTEXT;
|
|
39
42
|
let selected = explicitContext ?? contexts.read().current;
|
|
40
43
|
const urlReference = verb === 'sessions' ? rest.find(value => /^https?:\/\//.test(value)) : null;
|
|
@@ -145,6 +148,112 @@ async function loginCommand({ flags, origin, rest, home, output }) {
|
|
|
145
148
|
return signedIn(flags, output, await loginContext({ supercodeHome: home, name: flags.as ?? null, origin, teamId: flags.team ?? null, token, invite, as: flags.as ?? null, fetch: globalThis.fetch }));
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
/**
|
|
152
|
+
* `supercode auth`: one Volter sign-in for everything supercode does, the way `gh auth` signs in to GitHub.
|
|
153
|
+
* login [ORIGIN] sign in (or say who is), make that team current, and offer to connect this machine
|
|
154
|
+
* status who is signed in, which team, and whether this machine is connected
|
|
155
|
+
* switch [NAME] make another saved team current (without NAME, list them)
|
|
156
|
+
* logout forget the sign-in and every team it signed into (refused while this machine is connected)
|
|
157
|
+
* Everything a person reads goes to stdout: none of it is an error.
|
|
158
|
+
*/
|
|
159
|
+
async function authCommand({ flags, sub = 'status', rest, home, contexts, output, env }) {
|
|
160
|
+
// A person reads stdout; with --json it carries only the JSON, and progress goes to stderr.
|
|
161
|
+
const say = line => (flags.json ? process.stderr : process.stdout).write(`${line}\n`);
|
|
162
|
+
if (sub === 'login') {
|
|
163
|
+
only(flags, ['json', 'help', 'team', 'as', 'force', 'no_connect', 'cwd', 'volter_issuer']);
|
|
164
|
+
requireArgs(rest, 0, 1, 'auth login [ORIGIN] [--team TEAM] [--as NAME] [--force] [--no-connect] [--cwd DIR]');
|
|
165
|
+
const origin = rest[0] ?? env.SUPERCODE_TEAMS_ORIGIN ?? DEFAULT_TEAMS_ORIGIN;
|
|
166
|
+
let account = authAccount(home, contexts);
|
|
167
|
+
if (account.context && !flags.force && !flags.team && !rest[0]) {
|
|
168
|
+
if (flags.json) return output(account);
|
|
169
|
+
say(`✓ Already logged in to Volter as ${account.email ?? 'you'} · team ${account.team}`);
|
|
170
|
+
} else {
|
|
171
|
+
const signIn = { ...(flags.volter_issuer ? { issuer: flags.volter_issuer } : {}), audience: new URL(origin).origin, say };
|
|
172
|
+
// As `teams login`: --team signs into that team; otherwise the person chooses among the teams they may use
|
|
173
|
+
// (volter-login.mjs), and the context it leaves current is the one this login reports.
|
|
174
|
+
if (flags.team) {
|
|
175
|
+
const saved = await loginContext({ supercodeHome: home, name: flags.as ?? null, origin, teamId: flags.team, token: null, invite: null, identityToken: await volterDeviceSignIn(signIn), as: flags.as ?? null, fetch: globalThis.fetch });
|
|
176
|
+
const name = Object.entries(contexts.read().contexts).find(([, value]) => value.credential_ref === saved.credential_ref)?.[0];
|
|
177
|
+
if (name) contexts.use(name);
|
|
178
|
+
} else {
|
|
179
|
+
const signedInTo = await volterDeviceSignIn({ ...signIn, finish: ({ token, ask }) => signInToTeams({ supercodeHome: home, origin, identityToken: token, ask, as: flags.as ?? null }) });
|
|
180
|
+
for (const { team, reason } of signedInTo.failed ?? []) say(`Not signed in to ${team}: ${reason}`);
|
|
181
|
+
}
|
|
182
|
+
account = authAccount(home, contexts);
|
|
183
|
+
if (flags.json) return output(account);
|
|
184
|
+
say(`✓ Logged in to Volter as ${account.email ?? 'you'} · team ${account.team ?? 'none'}${account.others.length ? ` (also: ${account.others.join(', ')}; supercode auth switch)` : ''}`);
|
|
185
|
+
}
|
|
186
|
+
if (flags.no_connect || !account.context || !process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
187
|
+
const command = authTeamCommand(home, account.context, flags, output, env);
|
|
188
|
+
if (command.state().read()?.machine) return;
|
|
189
|
+
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
190
|
+
let folder;
|
|
191
|
+
try {
|
|
192
|
+
const yes = (await readline.question(`Connect this machine to ${account.team}, so its sessions show there and teammates you allow can reach it? [Y/n] `)).trim();
|
|
193
|
+
if (/^n/i.test(yes)) { say(`Not connected. Later: supercode teams connect`); return; }
|
|
194
|
+
folder = (await readline.question(`Folder to share [${flags.cwd ?? process.cwd()}]: `)).trim() || (flags.cwd ?? process.cwd());
|
|
195
|
+
} finally { readline.close(); }
|
|
196
|
+
// As `teams connect` from a terminal: a service where the platform has a manager, else connected while it runs
|
|
197
|
+
// (the prompt is closed first, so Ctrl+C reaches the connector rather than the prompt).
|
|
198
|
+
command.flags = { cwd: folder };
|
|
199
|
+
return await connectorCommand('connect', command, home);
|
|
200
|
+
}
|
|
201
|
+
if (sub === 'status') {
|
|
202
|
+
only(flags, ['json', 'help']); requireArgs(rest, 0, 0, 'auth status');
|
|
203
|
+
const account = authAccount(home, contexts);
|
|
204
|
+
if (account.context) {
|
|
205
|
+
const command = authTeamCommand(home, account.context, flags, output, env);
|
|
206
|
+
const enrollment = command.state().read();
|
|
207
|
+
account.machine = enrollment?.machine ? { id: enrollment.machine.id, name: enrollment.machine.name, folder: enrollment.roots?.[0]?.path ?? null, service: enrollment.service?.kind ?? null, connection: null } : null;
|
|
208
|
+
try { await command.client.me(); account.reachable = true; } catch { account.reachable = false; }
|
|
209
|
+
if (account.machine && account.reachable) account.machine.connection = (await command.team.machines.get(account.machine.id).catch(() => null))?.data?.connection ?? null;
|
|
210
|
+
}
|
|
211
|
+
if (flags.json) return output(account);
|
|
212
|
+
say(account.email ? `Volter ✓ ${account.email}` : 'Volter not logged in (supercode login)');
|
|
213
|
+
if (!account.context) return say(account.others.length ? `Team none current · saved: ${account.others.join(', ')} (supercode auth switch NAME)` : 'Team none (supercode login)');
|
|
214
|
+
say(`Team ${account.team} · ${new URL(account.origin).host}${account.reachable ? '' : ' · unreachable'}${account.others.length ? ` · also: ${account.others.join(', ')} (supercode auth switch)` : ''}`);
|
|
215
|
+
say(account.machine ? `Machine ${account.machine.name} · ${account.machine.connection ?? 'unknown'} · sharing ${account.machine.folder ?? '?'}${account.machine.service ? ` (${account.machine.service})` : ' (runs while `teams connect` does)'}` : 'Machine not connected (supercode teams connect)');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (sub === 'switch') {
|
|
219
|
+
only(flags, ['json', 'help']); requireArgs(rest, 0, 1, 'auth switch [NAME]');
|
|
220
|
+
const doc = contexts.read(), names = Object.keys(doc.contexts);
|
|
221
|
+
if (!rest[0]) return flags.json ? output({ current: doc.current, contexts: names }) : say(names.map(name => `${name === doc.current ? '*' : ' '} ${name} · ${doc.contexts[name].team_name ?? doc.contexts[name].team_id}`).join('\n') || 'No saved teams (supercode login)');
|
|
222
|
+
contexts.use(rest[0]);
|
|
223
|
+
return say(`✓ Switched to ${doc.contexts[rest[0]]?.team_name ?? rest[0]}`);
|
|
224
|
+
}
|
|
225
|
+
if (sub === 'logout') {
|
|
226
|
+
only(flags, ['json', 'help']); requireArgs(rest, 0, 0, 'auth logout');
|
|
227
|
+
const account = authAccount(home, contexts), doc = contexts.read();
|
|
228
|
+
// One sign-in is one credential, shared by every team context it signed into, so logging out ends all of them.
|
|
229
|
+
const credentialRef = account.context ? doc.contexts[account.context]?.credential_ref : null;
|
|
230
|
+
const signedIn = Object.entries(doc.contexts).filter(([name, context]) => name === account.context || (credentialRef && context.credential_ref === credentialRef));
|
|
231
|
+
// A connected machine's connector runs as one of those contexts, so logging out would strand it: disconnect first.
|
|
232
|
+
const connected = signedIn.find(([, context]) => contexts.connector(context.server_id, context.team_id).read()?.machine);
|
|
233
|
+
if (connected) throw Object.assign(new Error(`this machine is connected to ${connected[1].team_name ?? connected[0]}; run supercode teams disconnect first, then log out`), { code: 'conflict', exitCode: 6 });
|
|
234
|
+
const signedOut = signOut();
|
|
235
|
+
for (const [name] of signedIn) contexts.remove(name);
|
|
236
|
+
if (credentialRef) rmSync(join(contexts.paths.credentials, `${credentialRef}.json`), { force: true });
|
|
237
|
+
const teams = signedIn.map(([name, context]) => context.team_name ?? name);
|
|
238
|
+
if (flags.json) return output({ signed_out: signedOut, removed_contexts: signedIn.map(([name]) => name) });
|
|
239
|
+
return say(signedOut || teams.length ? `✓ Logged out${account.email ? ` ${account.email}` : ''}${teams.length ? ` from ${teams.join(', ')}` : ''}; every Volter tool on this machine is signed out` : 'Not logged in');
|
|
240
|
+
}
|
|
241
|
+
usage('auth login|status|switch|logout');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Who is signed in here: the machine's shared Volter sign-in and the current team context. */
|
|
245
|
+
function authAccount(home, contexts) {
|
|
246
|
+
const doc = contexts.read(), name = doc.current && doc.current !== 'local' ? doc.current : null, context = name ? doc.contexts[name] : null;
|
|
247
|
+
const shared = readSharedSignIn();
|
|
248
|
+
return { email: shared?.email ?? null, context: context ? name : null, team: context?.team_name ?? context?.team_id ?? null, origin: context?.origin ?? null, serverId: context?.server_id ?? null, teamId: context?.team_id ?? null, others: Object.keys(doc.contexts).filter(other => other !== name) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The command a team verb runs with, for the current team context. */
|
|
252
|
+
function authTeamCommand(home, contextName, flags, output, env) {
|
|
253
|
+
const { client, context } = fromContext({ supercodeHome: home, name: contextName, fetch: globalThis.fetch });
|
|
254
|
+
return { flags, rest: [], output, client, context, team: client.team(context.team_id), home, contextName, env, dependencies: {}, state: () => new WorkspaceContexts(home).connector(context.server_id, context.team_id) };
|
|
255
|
+
}
|
|
256
|
+
|
|
148
257
|
/** What a login says: the context as JSON, or to a person, the team they are in and the next step. */
|
|
149
258
|
function signedIn(flags, output, saved) {
|
|
150
259
|
if (flags.json) return output(saved);
|
|
@@ -536,10 +645,18 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
536
645
|
saved = state.update(current => ({ ...current, roots: [root] }));
|
|
537
646
|
// Run from a terminal, connect installs the service that keeps this machine connected (launchd or systemd) and
|
|
538
647
|
// returns; the service itself runs connect without a terminal, in the foreground. --foreground runs it here.
|
|
648
|
+
let service = null;
|
|
539
649
|
if (flags.install || (!flags.foreground && process.stdin.isTTY && process.stdout.isTTY)) {
|
|
540
650
|
const serviceBin = process.env.SUPERCODE_BIN;
|
|
541
651
|
if (!serviceBin) unsupported('--install requires invocation through the native supercode CLI');
|
|
542
|
-
|
|
652
|
+
try { service = JSON.parse(execFileSync(serviceBin, ['__teams-connector-service', 'install', contextName, path, context.server_id, context.team_id], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); }
|
|
653
|
+
catch (error) {
|
|
654
|
+
// A platform with no service manager still connects: here, for as long as this terminal runs it. Anything else is an error.
|
|
655
|
+
if (flags.install || !/no service manager/.test(String(error.stderr ?? error.message))) { core.close(); throw error; }
|
|
656
|
+
process.stderr.write('No service manager on this platform, so this machine stays connected while this command runs (Ctrl+C to stop).\n');
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (service) {
|
|
543
660
|
saved = state.update(current => ({ ...current, service }));
|
|
544
661
|
core.close();
|
|
545
662
|
if (!flags.json) {
|
|
@@ -562,7 +679,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
562
679
|
const stopAll = async () => { await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
|
|
563
680
|
try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
|
|
564
681
|
output({ version: 1, context: scope(context), data: { machine: saved.machine, source: saved.source, roots: saved.roots.map(({ path: _path, ...item }) => item), socket: machine.socketPath, foreground: true } });
|
|
565
|
-
await new Promise(resolveStop => { let stopped = false; stopConnector = async () => { if (stopped) return; stopped = true; await stopAll(); resolveStop(); };
|
|
682
|
+
await new Promise(resolveStop => { let stopped = false; stopConnector = async () => { if (stopped) return; stopped = true; await stopAll(); resolveStop(); }; for (const signal of ['SIGINT', 'SIGTERM', ...(process.platform === 'win32' ? ['SIGHUP', 'SIGBREAK'] : [])]) process.once(signal, stopConnector); });
|
|
566
683
|
return;
|
|
567
684
|
}
|
|
568
685
|
const enrollment = state.read();
|
|
@@ -572,7 +689,8 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
572
689
|
if (!serviceBin) unsupported('disconnecting an installed connector requires invocation through the native supercode CLI');
|
|
573
690
|
execFileSync(serviceBin, ['__teams-connector-service', 'uninstall', contextName, enrollment.roots?.[0]?.path ?? '.', context.server_id, context.team_id], { encoding: 'utf8' });
|
|
574
691
|
}
|
|
575
|
-
|
|
692
|
+
// Uninstalling a Windows task already stopped its connector, so only one still answering is asked to stop.
|
|
693
|
+
if (enrollment.process?.socket && enrollment.process?.token && await activeSocket(enrollment.process.socket)) await requestStop(enrollment.process.socket, enrollment.process.token);
|
|
576
694
|
const machine = await team.machines.get(enrollment.machine.id);
|
|
577
695
|
await client.request('DELETE', teamPath(context, `/machines/${enrollment.machine.id}`), { ifMatch: machine.data.revision });
|
|
578
696
|
state.remove();
|
|
@@ -707,6 +825,7 @@ function printHelp() {
|
|
|
707
825
|
process.stdout.write(`supercode workspace commands:
|
|
708
826
|
context create|list|show|use|remove
|
|
709
827
|
teams list|create
|
|
828
|
+
auth login|status|switch|logout (one Volter sign-in; "supercode login" is "auth login")
|
|
710
829
|
login [ORIGIN] [--team TEAM] [--as CONTEXT] [--token-stdin|--invite-stdin] (Volter sign-in in the browser by default)
|
|
711
830
|
identity link
|
|
712
831
|
server start [--volter-issuer ORIGIN] [--delegated-issuer ORIGIN]
|
package/connector/index.mjs
CHANGED
|
@@ -96,8 +96,18 @@ export class TeamsConnector {
|
|
|
96
96
|
const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
|
|
97
97
|
if(!locator||!nativeSessionId||!harness)continue;
|
|
98
98
|
const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:root.path??root.workspace};
|
|
99
|
-
|
|
99
|
+
// One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
|
|
100
|
+
// is recorded, logged and skipped; it must not stop every other session, or the connector itself, from
|
|
101
|
+
// syncing. An error of the connection itself (the outbox, authentication, the server, a stop) still ends
|
|
102
|
+
// the pass. The session keeps its native binding either way, so it can still be attached to.
|
|
103
|
+
let captured = true;
|
|
104
|
+
try { await this.capture(options); } catch (error) {
|
|
105
|
+
if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
|
|
106
|
+
captured = false; this.lastError = error;
|
|
107
|
+
process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
|
|
108
|
+
}
|
|
100
109
|
try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
|
|
110
|
+
if (!captured) continue;
|
|
101
111
|
const key=`${rule.id}:${harness}:${nativeSessionId}`;
|
|
102
112
|
if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
|
|
103
113
|
const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
|
|
@@ -169,6 +179,8 @@ export class TeamsConnector {
|
|
|
169
179
|
this.work=work.catch(()=>{});
|
|
170
180
|
return work;
|
|
171
181
|
}
|
|
172
|
-
|
|
182
|
+
// Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
|
|
183
|
+
async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
|
|
184
|
+
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);let receipt;if(begun.state==='committed')receipt=begun.receipt;else{for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);for(const artifact of item.artifacts??[])for(const chunk of artifact.chunks)await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);receipt=await this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;}this.state.origins[key]=next;this.state.outbox.shift();this.save();latest=receipt;}return latest;}
|
|
173
185
|
stop(){if(this.timer)clearInterval(this.timer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
174
186
|
}
|
package/machine.mjs
CHANGED
|
@@ -33,6 +33,9 @@ import { hostname } from 'node:os';
|
|
|
33
33
|
import { randomBytes, createHash } from 'node:crypto';
|
|
34
34
|
import { WebSocket } from 'ws';
|
|
35
35
|
import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
|
|
36
|
+
// The ConPTY host is read from the namespace, where a terminal package too old to have it gives undefined rather
|
|
37
|
+
// than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
|
|
38
|
+
import * as terminal from '@volter/supercode-terminal';
|
|
36
39
|
import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
|
|
37
40
|
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
38
41
|
import { holds, observesPanes } from '@volter/teams/admission';
|
|
@@ -75,7 +78,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
75
78
|
this.supercodeBin = supercode;
|
|
76
79
|
this.tmuxSocket = tmuxSocket ?? null;
|
|
77
80
|
this.launches = new Launches(this.p.launches);
|
|
78
|
-
this
|
|
81
|
+
// Windows has no tmux: its panes are ConPTY children of this daemon, and end with it.
|
|
82
|
+
if (process.platform === 'win32' && !terminal.PtyTerminalHost) throw new Error('panes on Windows need a newer @volter/supercode-terminal (one with PtyTerminalHost)');
|
|
83
|
+
this.host = process.platform === 'win32' ? new terminal.PtyTerminalHost() : new TmuxTerminalHost({
|
|
79
84
|
// Stable owner id so panes this daemon launched stay "owned" across restarts.
|
|
80
85
|
ownerId: `supercode-teams-${fingerprint(this.identity.publicKeyPem)}`,
|
|
81
86
|
...(tmuxSocket ? { socket: tmuxSocket } : {}),
|
|
@@ -376,7 +381,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
376
381
|
#paneEnv(contextKey, requested) {
|
|
377
382
|
const env = { ...(requested ?? {}) };
|
|
378
383
|
const inherited = env.NODE_OPTIONS ?? this.env.NODE_OPTIONS ?? '';
|
|
379
|
-
|
|
384
|
+
// Quoted: NODE_OPTIONS splits on spaces, and a home path may hold one (Windows user folders often do).
|
|
385
|
+
env.NODE_OPTIONS = `${inherited} --require ${JSON.stringify(PORTHOLE_HOOK)}`.trim();
|
|
380
386
|
env.SUPERCODE_TEAMS_PORTS_DIR = this.portsDir;
|
|
381
387
|
env.SUPERCODE_TEAMS_PANE = contextKey;
|
|
382
388
|
return env;
|
|
@@ -630,6 +636,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
630
636
|
async #renameWindow(contextKey, title) {
|
|
631
637
|
const session = await this.#paneByContext(contextKey);
|
|
632
638
|
if (!session || !session.owned) return null;
|
|
639
|
+
// A host with windows of its own renames them itself (a ConPTY pane has none).
|
|
640
|
+
if (typeof this.host.renameWindow === 'function') return this.host.renameWindow(session.id, title);
|
|
633
641
|
const socketArgs = this.tmuxSocket ? ['-L', this.tmuxSocket] : [];
|
|
634
642
|
const target = `${session.label}:0`;
|
|
635
643
|
const run = (args) => spawnSync('tmux', [...socketArgs, ...args], { timeout: 5_000, encoding: 'utf8' });
|
|
@@ -1149,7 +1157,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1149
1157
|
* pane's tty. When that is the pane's own shell, no command is running and the
|
|
1150
1158
|
* shell is at its prompt; when it is anything else, that thing is the work. The
|
|
1151
1159
|
* list is names, not paths, because that is the form tmux reports. */
|
|
1152
|
-
const PANE_SHELLS = new Set(['ash', 'bash', 'csh', 'dash', 'fish', 'ksh', 'ksh93', 'nu', 'pwsh', 'sh', 'tcsh', 'xonsh', 'zsh']);
|
|
1160
|
+
const PANE_SHELLS = new Set(['ash', 'bash', 'cmd', 'cmd.exe', 'csh', 'dash', 'fish', 'ksh', 'ksh93', 'nu', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe', 'sh', 'tcsh', 'xonsh', 'zsh']);
|
|
1153
1161
|
|
|
1154
1162
|
/** Liveness of a live pane from its foreground command (attention.mjs LIVENESS).
|
|
1155
1163
|
* A missing or empty value is `unknown`, the honest floor: tmux named nothing,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,7 +62,9 @@
|
|
|
62
62
|
"@volter/supercode-harness-sdk": "^0.3.43",
|
|
63
63
|
"@volter/supercode-terminal": "^0.2.30",
|
|
64
64
|
"@volter/identity": "^0.4.0",
|
|
65
|
-
"@volter/teams": "^0.1.
|
|
65
|
+
"@volter/teams": "^0.1.7",
|
|
66
|
+
"@xterm/addon-serialize": "^0.14.0",
|
|
67
|
+
"@xterm/headless": "^6.0.0",
|
|
66
68
|
"protobufjs": "8.8.0",
|
|
67
69
|
"ws": "^8.21.0"
|
|
68
70
|
},
|