@cordfuse/crosstalk 7.0.0-alpha.6 → 7.0.0-alpha.7
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/bin/crosstalk.js +1 -0
- package/commands/auth.js +218 -0
- package/commands/chat.js +86 -20
- package/commands/init.js +141 -86
- package/commands/restart.js +10 -3
- package/commands/up.js +3 -1
- package/lib/api-client.js +9 -2
- package/lib/resolve.js +12 -1
- package/package.json +1 -1
package/bin/crosstalk.js
CHANGED
package/commands/auth.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// crosstalk auth — manage agent auth tokens/keys.
|
|
2
|
+
//
|
|
3
|
+
// One subverb: `paste`. Operator runs the agent's headless-token
|
|
4
|
+
// generator on their host (`claude setup-token`, etc.), pastes the
|
|
5
|
+
// resulting token at our prompt, and we persist it in the container's
|
|
6
|
+
// env so subsequent dispatch + chat invocations are authenticated.
|
|
7
|
+
//
|
|
8
|
+
// Storage: <base>/<name>/auth.env (0600, operator-UID owned). Docker
|
|
9
|
+
// Compose's `env_file:` references it; each line is `KEY=value`.
|
|
10
|
+
// Existing keys are replaced in-place; new keys append. Empty lines
|
|
11
|
+
// and `#` comments preserved.
|
|
12
|
+
//
|
|
13
|
+
// Why this exists: 5 of the 6 supported agents work cleanly with env-
|
|
14
|
+
// var auth (the headless path), but none of them have a uniform "set
|
|
15
|
+
// the token" UX inside the container. This verb is the operator's
|
|
16
|
+
// one-stop shop — same UX for any agent.
|
|
17
|
+
//
|
|
18
|
+
// Per-agent env var (the audit on 2026-06-14 confirmed each):
|
|
19
|
+
// claude → CLAUDE_CODE_OAUTH_TOKEN (preferred: `claude setup-token`)
|
|
20
|
+
// ANTHROPIC_API_KEY (alternate: Console API key)
|
|
21
|
+
// codex → OPENAI_API_KEY (preferred)
|
|
22
|
+
// CODEX_ACCESS_TOKEN (alternate: device-auth output)
|
|
23
|
+
// gemini → GEMINI_API_KEY
|
|
24
|
+
// qwen → OPENAI_API_KEY (qwen --auth-type openai)
|
|
25
|
+
// agy → none (interactive OAuth only; errors with hint)
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
28
|
+
import { createInterface } from 'readline';
|
|
29
|
+
import { has, flag } from '../lib/argv.js';
|
|
30
|
+
import { requireInitialized, DEFAULT_CONTAINER_NAME } from '../lib/resolve.js';
|
|
31
|
+
|
|
32
|
+
const AGENT_ENV = {
|
|
33
|
+
claude: { default: 'CLAUDE_CODE_OAUTH_TOKEN', label: 'OAuth token' },
|
|
34
|
+
codex: { default: 'OPENAI_API_KEY', label: 'API key' },
|
|
35
|
+
gemini: { default: 'GEMINI_API_KEY', label: 'API key' },
|
|
36
|
+
qwen: { default: 'OPENAI_API_KEY', label: 'API key' },
|
|
37
|
+
opencode: { default: 'OPENAI_API_KEY', label: 'provider API key' },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const AGENT_GEN_CMD = {
|
|
41
|
+
claude: 'claude setup-token # on a host with a browser',
|
|
42
|
+
codex: 'echo $OPENAI_API_KEY # or platform.openai.com/api-keys',
|
|
43
|
+
gemini: 'echo $GEMINI_API_KEY # or aistudio.google.com/apikey',
|
|
44
|
+
qwen: 'echo $OPENAI_API_KEY # OpenAI-compatible',
|
|
45
|
+
opencode: 'echo $OPENAI_API_KEY # or provider-specific',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Control byte values used by the TTY prompt — avoid embedding raw
|
|
49
|
+
// bytes inline so the file is editor/linter clean.
|
|
50
|
+
const CTRL_C = String.fromCharCode(3); // 0x03 — interrupt
|
|
51
|
+
const CTRL_D = String.fromCharCode(4); // 0x04 — EOF
|
|
52
|
+
const BS = String.fromCharCode(8); // 0x08 — backspace
|
|
53
|
+
const DEL = String.fromCharCode(127); // 0x7F — terminal backspace
|
|
54
|
+
|
|
55
|
+
function usage(exit = 0) {
|
|
56
|
+
const w = exit === 0 ? process.stdout : process.stderr;
|
|
57
|
+
w.write(
|
|
58
|
+
`Usage: crosstalk auth paste --agent <name> [--containername <name>]
|
|
59
|
+
|
|
60
|
+
Persists an auth token/key for an agent CLI into the container's
|
|
61
|
+
environment. Operator generates the value on the host (which has a
|
|
62
|
+
browser) and pastes at the prompt; crosstalk writes it to
|
|
63
|
+
<base>/<name>/auth.env. Subsequent dispatch + chat invocations pick
|
|
64
|
+
it up via the engine container's env.
|
|
65
|
+
|
|
66
|
+
Options:
|
|
67
|
+
--agent <name> One of: claude, codex, gemini, qwen, opencode.
|
|
68
|
+
agy is interactive-only; use 'crosstalk chat
|
|
69
|
+
--agent agy --login' for OAuth instead.
|
|
70
|
+
--containername <name> Which crosstalk container to set the env on
|
|
71
|
+
(default: 'crosstalk').
|
|
72
|
+
-c <name> Shorthand for --containername.
|
|
73
|
+
|
|
74
|
+
Reads from stdin (hidden) — if the input isn't a TTY, the entire stdin
|
|
75
|
+
is read as the token (e.g. piped from a setup script).
|
|
76
|
+
|
|
77
|
+
After paste, run 'crosstalk restart' to pick up the new env.
|
|
78
|
+
`,
|
|
79
|
+
);
|
|
80
|
+
process.exit(exit);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function promptHidden(promptText) {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
process.stderr.write(promptText);
|
|
86
|
+
if (!process.stdin.isTTY) {
|
|
87
|
+
let buf = '';
|
|
88
|
+
process.stdin.on('data', (chunk) => { buf += chunk.toString(); });
|
|
89
|
+
process.stdin.on('end', () => resolve(buf.trim()));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (typeof process.stdin.setRawMode !== 'function') {
|
|
93
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
94
|
+
rl.question('', (ans) => { rl.close(); resolve(ans.trim()); });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
process.stdin.setRawMode(true);
|
|
98
|
+
process.stdin.resume();
|
|
99
|
+
process.stdin.setEncoding('utf-8');
|
|
100
|
+
let buf = '';
|
|
101
|
+
const onData = (chunk) => {
|
|
102
|
+
for (const ch of chunk) {
|
|
103
|
+
if (ch === '\r' || ch === '\n') {
|
|
104
|
+
process.stderr.write('\n');
|
|
105
|
+
process.stdin.setRawMode(false);
|
|
106
|
+
process.stdin.pause();
|
|
107
|
+
process.stdin.removeListener('data', onData);
|
|
108
|
+
resolve(buf.trim());
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (ch === CTRL_C || ch === CTRL_D) {
|
|
112
|
+
process.stderr.write('\n');
|
|
113
|
+
process.stdin.setRawMode(false);
|
|
114
|
+
process.exit(130);
|
|
115
|
+
}
|
|
116
|
+
if (ch === BS || ch === DEL) {
|
|
117
|
+
buf = buf.slice(0, -1);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
buf += ch;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
process.stdin.on('data', onData);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Upsert a KEY=value line in env-file content. Preserves other lines +
|
|
128
|
+
// comments. Returns the new content.
|
|
129
|
+
function upsertEnv(existing, key, value) {
|
|
130
|
+
const lines = existing.split('\n');
|
|
131
|
+
let replaced = false;
|
|
132
|
+
const out = lines.map((line) => {
|
|
133
|
+
if (line.startsWith(`${key}=`)) {
|
|
134
|
+
replaced = true;
|
|
135
|
+
return `${key}=${value}`;
|
|
136
|
+
}
|
|
137
|
+
return line;
|
|
138
|
+
});
|
|
139
|
+
if (!replaced) {
|
|
140
|
+
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
141
|
+
out.push(`${key}=${value}`);
|
|
142
|
+
out.push('');
|
|
143
|
+
}
|
|
144
|
+
return out.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function run(argv) {
|
|
148
|
+
if (has(argv, '--help') || has(argv, '-h')) usage(0);
|
|
149
|
+
|
|
150
|
+
const pos = argv.filter((a) => !a.startsWith('-'));
|
|
151
|
+
const subverb = pos[0];
|
|
152
|
+
if (subverb !== 'paste') {
|
|
153
|
+
if (subverb) {
|
|
154
|
+
process.stderr.write(`crosstalk auth: unknown subverb '${subverb}'. Try 'paste'.\n`);
|
|
155
|
+
} else {
|
|
156
|
+
usage(1);
|
|
157
|
+
}
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const agent = flag(argv, '--agent');
|
|
162
|
+
if (!agent) {
|
|
163
|
+
process.stderr.write(`crosstalk auth paste: --agent <name> is required.\n`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (agent === 'agy') {
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
`crosstalk auth paste: agy has no headless env-var path — its only auth is interactive OAuth.\n` +
|
|
170
|
+
` Use 'crosstalk chat --agent agy --login' to set up auth.\n`,
|
|
171
|
+
);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cfg = AGENT_ENV[agent];
|
|
176
|
+
if (!cfg) {
|
|
177
|
+
process.stderr.write(
|
|
178
|
+
`crosstalk auth paste: unknown agent '${agent}'.\n` +
|
|
179
|
+
` Supported: ${Object.keys(AGENT_ENV).join(', ')}.\n`,
|
|
180
|
+
);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { name, paths } = requireInitialized(argv);
|
|
185
|
+
|
|
186
|
+
if (process.stdin.isTTY) {
|
|
187
|
+
process.stderr.write(
|
|
188
|
+
`\nPasting ${cfg.label} for ${agent} into '${name}' container.\n` +
|
|
189
|
+
` Env var: ${cfg.default}\n` +
|
|
190
|
+
` Storage: ${paths.authEnvFile}\n` +
|
|
191
|
+
`\nGenerate on host:\n ${AGENT_GEN_CMD[agent] ?? '(see agent docs)'}\n` +
|
|
192
|
+
`\nPaste the value below (input hidden, press Enter):\n`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const value = await promptHidden('> ');
|
|
196
|
+
if (!value) {
|
|
197
|
+
process.stderr.write(`crosstalk auth paste: empty input — aborted.\n`);
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
if (value.includes('\n')) {
|
|
201
|
+
process.stderr.write(
|
|
202
|
+
`crosstalk auth paste: input contains a newline. Tokens are single-line; check copy/paste.\n`,
|
|
203
|
+
);
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const existing = existsSync(paths.authEnvFile)
|
|
208
|
+
? readFileSync(paths.authEnvFile, 'utf-8')
|
|
209
|
+
: '';
|
|
210
|
+
const updated = upsertEnv(existing, cfg.default, value);
|
|
211
|
+
writeFileSync(paths.authEnvFile, updated, { mode: 0o600 });
|
|
212
|
+
|
|
213
|
+
process.stdout.write(
|
|
214
|
+
`\nSaved ${cfg.default} to ${paths.authEnvFile}.\n` +
|
|
215
|
+
`Run 'crosstalk restart${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to pick up the new env.\n`,
|
|
216
|
+
);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
package/commands/chat.js
CHANGED
|
@@ -132,6 +132,17 @@ Examples:
|
|
|
132
132
|
process.exit(exit);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Detect host terminal dims with sane fallbacks. Pass into the
|
|
136
|
+
// container via -e COLUMNS/LINES so the agent CLI renders at the
|
|
137
|
+
// operator's real terminal size instead of docker's 80×24 fallback.
|
|
138
|
+
// (When stdout is piped — like in --login — docker can't auto-detect
|
|
139
|
+
// the host's stdout terminal, which is mac's alpha.6 bug D.)
|
|
140
|
+
function hostTermDims() {
|
|
141
|
+
const cols = process.stdout.columns || process.stderr.columns || 120;
|
|
142
|
+
const rows = process.stdout.rows || process.stderr.rows || 40;
|
|
143
|
+
return { cols, rows };
|
|
144
|
+
}
|
|
145
|
+
|
|
135
146
|
function runInteractive(container, command, env = {}) {
|
|
136
147
|
// Full TTY passthrough — docker exec -it gives the container a real
|
|
137
148
|
// PTY, and stdio: 'inherit' forwards stdin/stdout/stderr from the
|
|
@@ -147,30 +158,79 @@ function runInteractive(container, command, env = {}) {
|
|
|
147
158
|
return r.status ?? 1;
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
// Per-agent login command. Returns null for agents without an OAuth
|
|
162
|
+
// path (gemini, qwen, opencode) — these need env-var auth, not browser
|
|
163
|
+
// OAuth. The full audit (cachy 2026-06-14) confirmed each agent's
|
|
164
|
+
// surface:
|
|
165
|
+
// claude → `claude auth login` (clean single-line URL output)
|
|
166
|
+
// codex → `codex login` (clean single-line URL output)
|
|
167
|
+
// agy → `agy --print init` (triggers OAuth, clean URL)
|
|
168
|
+
// We use the explicit auth subcommand instead of bare invocation —
|
|
169
|
+
// that's mac's bug D fix root cause. Bare `claude` drops into a TUI
|
|
170
|
+
// with line wraps + winsize sensitivity that broke URL extraction
|
|
171
|
+
// across alpha.3 → alpha.6. The official auth subcommand prints a
|
|
172
|
+
// plain single-line URL on stdout, no TUI involved.
|
|
173
|
+
function loginCommand(agent) {
|
|
174
|
+
if (agent === 'claude') return ['claude', 'auth', 'login'];
|
|
175
|
+
if (agent === 'codex') return ['codex', 'login'];
|
|
176
|
+
if (agent === 'agy') return ['agy', '--print', 'init'];
|
|
177
|
+
return null; // gemini, qwen, opencode — env-based
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Per-agent env-var hint for the no-OAuth agents.
|
|
181
|
+
function envHint(agent) {
|
|
182
|
+
if (agent === 'gemini') return 'GEMINI_API_KEY';
|
|
183
|
+
if (agent === 'qwen') return 'OPENAI_API_KEY (qwen --auth-type openai)';
|
|
184
|
+
if (agent === 'opencode') return 'per-provider keys in opencode config';
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
150
188
|
function runLogin(container, agent) {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
// PTY
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
189
|
+
const cmd = loginCommand(agent);
|
|
190
|
+
if (!cmd) {
|
|
191
|
+
const env = envHint(agent);
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
`crosstalk chat --login --agent ${agent}: this agent has no browser OAuth flow.\n` +
|
|
194
|
+
(env ? ` Use environment-based auth instead:\n crosstalk auth paste --agent ${agent}\n (or set ${env} directly in docker-compose.yml)\n` : '') +
|
|
195
|
+
` Documented in GUIDE-CLI.md § Agent auth.\n`,
|
|
196
|
+
);
|
|
197
|
+
return Promise.resolve(1);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Pass host terminal dims explicitly. Docker exec -t normally sizes
|
|
201
|
+
// the container PTY off its stdout terminal, but we PIPE stdout (to
|
|
202
|
+
// scrape the OAuth URL), so docker has no TTY to size from and falls
|
|
203
|
+
// back to 80×24 — that's mac's bug D root cause. Setting COLUMNS/LINES
|
|
204
|
+
// env vars gets the agent CLI to render at the right size from the
|
|
205
|
+
// start. (Live SIGWINCH updates aren't propagated — docker env is
|
|
206
|
+
// set-at-spawn — but OAuth flows are short; operators don't typically
|
|
207
|
+
// resize the terminal mid-auth.)
|
|
208
|
+
const { cols, rows } = hostTermDims();
|
|
209
|
+
|
|
168
210
|
return new Promise((resolve) => {
|
|
169
211
|
const child = spawn(
|
|
170
212
|
'docker',
|
|
171
|
-
['exec', '-it',
|
|
213
|
+
['exec', '-it',
|
|
214
|
+
'-e', `COLUMNS=${cols}`,
|
|
215
|
+
'-e', `LINES=${rows}`,
|
|
216
|
+
'-e', 'TERM=xterm-256color',
|
|
217
|
+
container, ...cmd],
|
|
172
218
|
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
|
173
219
|
);
|
|
220
|
+
|
|
221
|
+
// SIGWINCH handler: docker env is set-at-spawn so we can't dynamically
|
|
222
|
+
// resize the in-container PTY without a control channel (would need
|
|
223
|
+
// node-pty or a sidecar process). Best-effort: surface that a resize
|
|
224
|
+
// happened so the operator knows why the TUI may not have responded.
|
|
225
|
+
const onWinch = () => {
|
|
226
|
+
const { cols: c, rows: r } = hostTermDims();
|
|
227
|
+
process.stderr.write(
|
|
228
|
+
`\n[crosstalk chat] Terminal resized to ${c}×${r} — in-container PTY won't update mid-session. ` +
|
|
229
|
+
`Exit (Ctrl-C) and retry if rendering is off.\n`,
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
process.on('SIGWINCH', onWinch);
|
|
233
|
+
|
|
174
234
|
let opened = false;
|
|
175
235
|
let buffer = '';
|
|
176
236
|
child.stdout.on('data', (chunk) => {
|
|
@@ -187,8 +247,14 @@ function runLogin(container, agent) {
|
|
|
187
247
|
: `\n[crosstalk chat] Could not auto-open browser. Open this URL manually:\n ${url}\n`,
|
|
188
248
|
);
|
|
189
249
|
});
|
|
190
|
-
child.on('exit', (code) =>
|
|
191
|
-
|
|
250
|
+
child.on('exit', (code) => {
|
|
251
|
+
process.removeListener('SIGWINCH', onWinch);
|
|
252
|
+
resolve(code ?? 0);
|
|
253
|
+
});
|
|
254
|
+
child.on('error', () => {
|
|
255
|
+
process.removeListener('SIGWINCH', onWinch);
|
|
256
|
+
resolve(1);
|
|
257
|
+
});
|
|
192
258
|
});
|
|
193
259
|
}
|
|
194
260
|
|
package/commands/init.js
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
// crosstalk init — scaffold a transport into <base>/<name>/.
|
|
2
2
|
//
|
|
3
|
-
// alpha.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// alpha.7 rewrite: phased + idempotent. Each phase is independent and
|
|
4
|
+
// safe to re-run; re-init detects missing markers and repairs them.
|
|
5
|
+
// Mac caught bug A in alpha.6 where the api-port file was written at
|
|
6
|
+
// the end of the pipeline, so any disruption (image pull, git failure)
|
|
7
|
+
// left storage existing but unstartable — and re-init silently
|
|
8
|
+
// no-op-ed instead of fixing it.
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// Phases:
|
|
11
|
+
// 1. Storage layout — mkdir subdirs + write api-port. Pure FS, fast,
|
|
12
|
+
// runs first so the resolver-visible marker exists before anything
|
|
13
|
+
// else can go wrong.
|
|
14
|
+
// 2. Transport scaffold — `crosstalkd init` in a one-shot container.
|
|
15
|
+
// Skipped if transport/.git already exists. stdout/stderr captured
|
|
16
|
+
// (mac's bug B — in-container scaffold output was leaking).
|
|
17
|
+
// 3. Git init + initial commit. Skipped if .git exists.
|
|
18
|
+
// 4. Remote setup if --remote given (errors if a different origin
|
|
19
|
+
// is already set; idempotent on same-value).
|
|
20
|
+
// 5. Summary.
|
|
12
21
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// bind-mounting transport/ as /init-target. The engine writes its
|
|
18
|
-
// template (CROSSTALK-VERSION, PROTOCOL.md, CROSSTALK.md, data/,
|
|
19
|
-
// local/) into the bind-mount.
|
|
20
|
-
// 3. `git init` inside transport/ + initial commit.
|
|
21
|
-
// 4. If --remote: `git remote add origin <url>`.
|
|
22
|
-
// 5. Pick a free API port (default container always 7000; named
|
|
23
|
-
// containers search from 7001) and write to <base>/<name>/api-port.
|
|
24
|
-
|
|
25
|
-
import { mkdirSync, existsSync, writeFileSync } from 'fs';
|
|
22
|
+
// Each phase reports created/skipped/repaired so operators can tell
|
|
23
|
+
// what just happened.
|
|
24
|
+
|
|
25
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync } from 'fs';
|
|
26
26
|
import { spawnSync } from 'child_process';
|
|
27
|
+
import { join } from 'path';
|
|
27
28
|
import { has, flag } from '../lib/argv.js';
|
|
28
29
|
import {
|
|
29
30
|
parseContainerName,
|
|
30
31
|
storagePaths,
|
|
31
|
-
isInitialized,
|
|
32
32
|
pickFreePort,
|
|
33
33
|
DEFAULT_CONTAINER_NAME,
|
|
34
34
|
} from '../lib/resolve.js';
|
|
35
35
|
|
|
36
36
|
const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
|
|
37
|
-
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.
|
|
37
|
+
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.7';
|
|
38
38
|
|
|
39
39
|
function usage(exit = 0) {
|
|
40
40
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
@@ -42,8 +42,11 @@ function usage(exit = 0) {
|
|
|
42
42
|
`Usage:
|
|
43
43
|
crosstalk init [--containername <name>] [--remote <url>]
|
|
44
44
|
|
|
45
|
-
Creates a new transport on this machine
|
|
46
|
-
|
|
45
|
+
Creates a new transport on this machine, or repairs an existing
|
|
46
|
+
incomplete one. Idempotent — re-running fills in any missing markers
|
|
47
|
+
without disturbing existing state.
|
|
48
|
+
|
|
49
|
+
The transport git repo lives at <base>/<name>/transport/ where <base> is:
|
|
47
50
|
Linux: $XDG_DATA_HOME/crosstalk/ (or ~/.local/share/crosstalk/)
|
|
48
51
|
macOS: ~/Library/Application Support/crosstalk/
|
|
49
52
|
Windows: %LOCALAPPDATA%\\crosstalk\\
|
|
@@ -55,8 +58,8 @@ Options:
|
|
|
55
58
|
--containername <name> Container name (default: 'crosstalk'). Must be
|
|
56
59
|
lowercase alphanumeric + ._- (no leading . or -).
|
|
57
60
|
One default + N named containers per machine.
|
|
58
|
-
--remote <url> Set git upstream
|
|
59
|
-
|
|
61
|
+
--remote <url> Set git upstream. If a different remote is
|
|
62
|
+
already set, errors instead of overwriting.
|
|
60
63
|
--image <tag> Override the engine image (default: bundled
|
|
61
64
|
GHCR tag; or CROSSTALK_IMAGE env).
|
|
62
65
|
-c <name> Shorthand for --containername.
|
|
@@ -81,22 +84,15 @@ export async function run(argv) {
|
|
|
81
84
|
return 1;
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
if (isInitialized(name)) {
|
|
85
|
-
const paths = storagePaths(name);
|
|
86
|
-
process.stderr.write(
|
|
87
|
-
`crosstalk init: transport '${name}' already initialized at ${paths.transportDir}.\n` +
|
|
88
|
-
` Run 'crosstalk up${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to start it,\n` +
|
|
89
|
-
` or 'crosstalk rm${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to wipe it first.\n`,
|
|
90
|
-
);
|
|
91
|
-
return 1;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
87
|
const remote = flag(argv, '--remote');
|
|
95
88
|
const image = flag(argv, '--image') ?? DEFAULT_IMAGE;
|
|
96
89
|
const paths = storagePaths(name);
|
|
90
|
+
const report = [];
|
|
97
91
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
92
|
+
// ── Phase 1: Storage layout ────────────────────────────────────────
|
|
93
|
+
// mkdir is idempotent (recursive: true). Write api-port if absent.
|
|
94
|
+
// This is the load-bearing fix for bug A — port file lands FIRST,
|
|
95
|
+
// before anything else can go wrong (image pull, git init, etc.).
|
|
100
96
|
try {
|
|
101
97
|
mkdirSync(paths.transportDir, { recursive: true });
|
|
102
98
|
mkdirSync(paths.crosstalkRoot, { recursive: true });
|
|
@@ -114,81 +110,140 @@ export async function run(argv) {
|
|
|
114
110
|
return 1;
|
|
115
111
|
}
|
|
116
112
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
113
|
+
let port;
|
|
114
|
+
if (existsSync(paths.portFile)) {
|
|
115
|
+
const v = Number(readFileSync(paths.portFile, 'utf-8').trim());
|
|
116
|
+
if (Number.isInteger(v) && v > 0 && v < 65536) {
|
|
117
|
+
port = v;
|
|
118
|
+
report.push(` api port: ${port} (existing)`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!port) {
|
|
122
|
+
try {
|
|
123
|
+
port = pickFreePort(name);
|
|
124
|
+
writeFileSync(paths.portFile, `${port}\n`);
|
|
125
|
+
report.push(` api port: ${port} (allocated)`);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
process.stderr.write(`crosstalk init: port allocation failed — ${err.message}\n`);
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
132
131
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
132
|
+
// Ensure an empty auth.env exists so compose's env_file: can reference
|
|
133
|
+
// it without erroring on missing file. Operator-owned, 0600 — even
|
|
134
|
+
// when empty it's where `crosstalk auth paste` will land tokens.
|
|
135
|
+
if (!existsSync(paths.authEnvFile)) {
|
|
136
|
+
writeFileSync(paths.authEnvFile, '', { mode: 0o600 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Phase 2: Transport scaffold ────────────────────────────────────
|
|
140
|
+
// The template-scaffold check is "does CROSSTALK-VERSION exist?" NOT
|
|
141
|
+
// ".git exists" — git init is a separate phase, and the engine refuses
|
|
142
|
+
// to re-scaffold over an existing CROSSTALK-VERSION (without --force).
|
|
143
|
+
// Decoupling means a disrupted init (template written, git init never
|
|
144
|
+
// ran) re-enters Phase 3 cleanly on re-run.
|
|
145
|
+
const transportGitDir = join(paths.transportDir, '.git');
|
|
146
|
+
const crosstalkVersionFile = join(paths.transportDir, 'CROSSTALK-VERSION');
|
|
147
|
+
if (!existsSync(crosstalkVersionFile)) {
|
|
148
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
149
|
+
const gid = typeof process.getgid === 'function' ? process.getgid() : null;
|
|
150
|
+
const userArgs = (uid != null && gid != null) ? ['--user', `${uid}:${gid}`] : [];
|
|
151
|
+
|
|
152
|
+
const dockerArgs = [
|
|
153
|
+
'run', '--rm',
|
|
154
|
+
'-v', `${paths.transportDir}:/init-target`,
|
|
155
|
+
...userArgs,
|
|
156
|
+
'--entrypoint', 'crosstalkd',
|
|
157
|
+
image,
|
|
158
|
+
'init', '/init-target',
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// Bug B fix: don't inherit stdio — the engine prints next-steps that
|
|
162
|
+
// confuse operators ("Transport initialized at /init-target" with
|
|
163
|
+
// container-internal paths). Capture, show only on failure.
|
|
164
|
+
const initRun = spawnSync('docker', dockerArgs, { stdio: 'pipe' });
|
|
165
|
+
if (initRun.status !== 0) {
|
|
166
|
+
const stderr = initRun.stderr?.toString() ?? '';
|
|
167
|
+
const stdout = initRun.stdout?.toString() ?? '';
|
|
168
|
+
process.stderr.write(`crosstalk init: docker run exited ${initRun.status}\n`);
|
|
169
|
+
if (stderr.trim()) process.stderr.write(` stderr: ${stderr.trim()}\n`);
|
|
170
|
+
if (stdout.trim()) process.stderr.write(` stdout: ${stdout.trim()}\n`);
|
|
171
|
+
process.stderr.write(` command was: docker ${dockerArgs.join(' ')}\n`);
|
|
172
|
+
return initRun.status ?? 1;
|
|
173
|
+
}
|
|
174
|
+
report.push(` template: scaffolded via ${image}`);
|
|
175
|
+
} else {
|
|
176
|
+
report.push(` template: present (skipped scaffold)`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Phase 3: Git init + initial commit ─────────────────────────────
|
|
180
|
+
if (!existsSync(crosstalkVersionFile)) {
|
|
181
|
+
// Engine init succeeded but didn't leave files. Bail clearly — we
|
|
182
|
+
// won't proceed to git init on an empty dir.
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`crosstalk init: transport template missing at ${paths.transportDir}\n` +
|
|
185
|
+
` Engine init didn't write template files. Try 'crosstalk rm' then re-init.\n`,
|
|
186
|
+
);
|
|
187
|
+
return 1;
|
|
138
188
|
}
|
|
139
189
|
|
|
140
|
-
// Auto git init + initial commit. Doing it on the host so the commit
|
|
141
|
-
// author uses the operator's local git config — not the container's.
|
|
142
190
|
const gitSteps = [
|
|
143
191
|
['init', '--quiet', '--initial-branch=main'],
|
|
144
192
|
['add', '-A'],
|
|
145
193
|
['commit', '--quiet', '-m', 'initial transport'],
|
|
146
194
|
];
|
|
195
|
+
let gitOk = true;
|
|
147
196
|
for (const args of gitSteps) {
|
|
148
197
|
const gr = spawnSync('git', args, { cwd: paths.transportDir, stdio: 'pipe' });
|
|
149
198
|
if (gr.status !== 0) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// Not fatal — storage exists and engine will tolerate uncommitted state
|
|
199
|
+
// 'git init' on existing repo is fine (it re-runs idempotently);
|
|
200
|
+
// 'add -A' is also idempotent. 'commit' fails when there's
|
|
201
|
+
// nothing to commit — that means a previous run already committed.
|
|
202
|
+
// Only the first call (git init) is load-bearing; the rest are
|
|
203
|
+
// best-effort to populate the initial state.
|
|
204
|
+
gitOk = false;
|
|
157
205
|
break;
|
|
158
206
|
}
|
|
159
207
|
}
|
|
208
|
+
report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
|
|
160
209
|
|
|
161
|
-
//
|
|
162
|
-
// operator can retry with `cd <transport-dir> && git remote add`.
|
|
210
|
+
// ── Phase 4: Remote setup ──────────────────────────────────────────
|
|
163
211
|
if (remote) {
|
|
164
|
-
const
|
|
212
|
+
const cur = spawnSync('git', ['remote', 'get-url', 'origin'], {
|
|
165
213
|
cwd: paths.transportDir,
|
|
166
214
|
stdio: 'pipe',
|
|
167
215
|
});
|
|
168
|
-
|
|
216
|
+
const currentUrl = cur.status === 0 ? cur.stdout.toString().trim() : null;
|
|
217
|
+
if (!currentUrl) {
|
|
218
|
+
const rr = spawnSync('git', ['remote', 'add', 'origin', remote], {
|
|
219
|
+
cwd: paths.transportDir,
|
|
220
|
+
stdio: 'pipe',
|
|
221
|
+
});
|
|
222
|
+
if (rr.status !== 0) {
|
|
223
|
+
process.stderr.write(
|
|
224
|
+
`crosstalk init: 'git remote add origin ${remote}' failed — ${rr.stderr?.toString().trim()}\n`,
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
report.push(` remote: ${remote} (added)`);
|
|
228
|
+
}
|
|
229
|
+
} else if (currentUrl === remote) {
|
|
230
|
+
report.push(` remote: ${remote} (already set)`);
|
|
231
|
+
} else {
|
|
169
232
|
process.stderr.write(
|
|
170
|
-
`crosstalk init:
|
|
233
|
+
`crosstalk init: remote already set to '${currentUrl}'; refusing to overwrite with '${remote}'.\n` +
|
|
234
|
+
` Use 'crosstalk status' to see the transport path; cd there and adjust git config manually if intended.\n`,
|
|
171
235
|
);
|
|
236
|
+
return 1;
|
|
172
237
|
}
|
|
173
238
|
}
|
|
174
239
|
|
|
175
|
-
//
|
|
176
|
-
let port;
|
|
177
|
-
try {
|
|
178
|
-
port = pickFreePort(name);
|
|
179
|
-
writeFileSync(paths.portFile, `${port}\n`);
|
|
180
|
-
} catch (err) {
|
|
181
|
-
process.stderr.write(`crosstalk init: port allocation failed — ${err.message}\n`);
|
|
182
|
-
return 1;
|
|
183
|
-
}
|
|
184
|
-
|
|
240
|
+
// ── Phase 5: Summary ───────────────────────────────────────────────
|
|
185
241
|
process.stdout.write(
|
|
186
|
-
`\nTransport '${name}'
|
|
242
|
+
`\nTransport '${name}' ready:\n` +
|
|
187
243
|
` storage: ${paths.storageRoot}\n` +
|
|
188
244
|
` transport: ${paths.transportDir}\n` +
|
|
189
245
|
` image: ${image}\n` +
|
|
190
|
-
|
|
191
|
-
(remote ? ` remote: ${remote}\n` : '') +
|
|
246
|
+
report.join('\n') + '\n' +
|
|
192
247
|
`\nNext: crosstalk up${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}\n`,
|
|
193
248
|
);
|
|
194
249
|
return 0;
|
package/commands/restart.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
// crosstalk restart — restart a transport's engine container.
|
|
2
|
+
//
|
|
3
|
+
// Uses `compose up -d --force-recreate` (not `compose restart`) so
|
|
4
|
+
// changes to env_file (e.g. tokens added via `crosstalk auth paste`)
|
|
5
|
+
// are picked up. Plain `compose restart` just stops + starts the same
|
|
6
|
+
// container without re-reading config.
|
|
2
7
|
|
|
3
8
|
import { existsSync } from 'fs';
|
|
4
9
|
import { spawnSync } from 'child_process';
|
|
@@ -15,8 +20,10 @@ export async function run(argv) {
|
|
|
15
20
|
process.stderr.write(`crosstalk restart: '${name}' has no compose file — run 'crosstalk up' first.\n`);
|
|
16
21
|
return 1;
|
|
17
22
|
}
|
|
18
|
-
const r = spawnSync(
|
|
19
|
-
|
|
20
|
-
|
|
23
|
+
const r = spawnSync(
|
|
24
|
+
'docker',
|
|
25
|
+
['compose', '-f', paths.composeFile, 'up', '-d', '--force-recreate'],
|
|
26
|
+
{ stdio: 'inherit' },
|
|
27
|
+
);
|
|
21
28
|
return r.status ?? 1;
|
|
22
29
|
}
|
package/commands/up.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from '../lib/resolve.js';
|
|
25
25
|
|
|
26
26
|
const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
|
|
27
|
-
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.
|
|
27
|
+
?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.7';
|
|
28
28
|
|
|
29
29
|
function usage(exit = 0) {
|
|
30
30
|
const w = exit === 0 ? process.stdout : process.stderr;
|
|
@@ -69,6 +69,8 @@ services:
|
|
|
69
69
|
- ${paths.transportDir}:/var/lib/crosstalk-transport
|
|
70
70
|
- ${paths.crosstalkRoot}:/crosstalk-root
|
|
71
71
|
- ${paths.crosstalkState}:/var/lib/crosstalk-state
|
|
72
|
+
env_file:
|
|
73
|
+
- ${paths.authEnvFile}
|
|
72
74
|
environment:
|
|
73
75
|
CROSSTALK_ALIAS: ${alias}
|
|
74
76
|
CROSSTALK_UID: "${uid}"
|
package/lib/api-client.js
CHANGED
|
@@ -111,9 +111,16 @@ async function call(method, path, body, opts = {}) {
|
|
|
111
111
|
|
|
112
112
|
// Bind an api client to a specific container name. All callers should
|
|
113
113
|
// either pass argv (auto-resolves name) or an explicit name. Internally
|
|
114
|
-
// each call resolves the port via the name's api-port file.
|
|
114
|
+
// each call resolves the port via the name's api-port file. Validation
|
|
115
|
+
// errors print cleanly (no stack trace) — bug C fix.
|
|
115
116
|
export function apiFor(argv) {
|
|
116
|
-
|
|
117
|
+
let name;
|
|
118
|
+
try {
|
|
119
|
+
name = parseContainerName(argv);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
process.stderr.write(`crosstalk: ${err.message}\n`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
117
124
|
return {
|
|
118
125
|
name,
|
|
119
126
|
get: (path, opts = {}) => call('GET', path, undefined, { ...opts, name }),
|
package/lib/resolve.js
CHANGED
|
@@ -66,6 +66,7 @@ export function storagePaths(name, mode = storageMode()) {
|
|
|
66
66
|
crosstalkState: join(root, 'crosstalk-state'),
|
|
67
67
|
composeFile: join(root, 'docker-compose.yml'),
|
|
68
68
|
portFile: join(root, 'api-port'),
|
|
69
|
+
authEnvFile: join(root, 'auth.env'),
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -168,8 +169,18 @@ export function containerExists(name) {
|
|
|
168
169
|
// Look up the resolved name+paths from argv, validate that init has run,
|
|
169
170
|
// and exit with a clear error if not. For verbs that operate on existing
|
|
170
171
|
// transports (up after init, status, chat, down, rm, etc).
|
|
172
|
+
//
|
|
173
|
+
// Validation errors from parseContainerName are caught here and printed
|
|
174
|
+
// cleanly — Mac's bug C from alpha.6 was a raw stack trace when an
|
|
175
|
+
// invalid --containername reached the resolver.
|
|
171
176
|
export function requireInitialized(argv) {
|
|
172
|
-
|
|
177
|
+
let name;
|
|
178
|
+
try {
|
|
179
|
+
name = parseContainerName(argv);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
process.stderr.write(`crosstalk: ${err.message}\n`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
173
184
|
if (!isInitialized(name)) {
|
|
174
185
|
process.stderr.write(
|
|
175
186
|
`crosstalk: no transport '${name}' on this machine.\n` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cordfuse/crosstalk",
|
|
3
|
-
"version": "7.0.0-alpha.
|
|
3
|
+
"version": "7.0.0-alpha.7",
|
|
4
4
|
"description": "Crosstalk client — host-side CLI that talks to the crosstalkd daemon running inside the crosstalk-server container.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|