@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 CHANGED
@@ -31,6 +31,7 @@ const SUBCOMMANDS = {
31
31
  up: 'up.js',
32
32
  down: 'down.js',
33
33
  rm: 'rm.js',
34
+ auth: 'auth.js',
34
35
  restart: 'restart.js',
35
36
  pull: 'pull.js',
36
37
  logs: 'logs.js',
@@ -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
- // Login mode: tee the agent's stdout so we can spot the auth URL and
152
- // open it in the operator's browser. The container still has a real
153
- // PTY (docker exec -it allocates one inside the container regardless
154
- // of host stdio configuration), so the agent CLI renders into a TTY;
155
- // we just pipe the bytes on the host side to inspect them.
156
- //
157
- // No agent-specific "login" subcommand — we just run the agent's
158
- // default interactive entry point. First-run auth flow happens
159
- // automatically for every agent we support.
160
- //
161
- // No COLUMNS=1000 env: making the agent render the URL "unwrapped" via
162
- // a wide terminal claim doesn't reliably propagate to the in-container
163
- // PTY, and even when it does Claude's TUI hard-wraps anyway. The
164
- // extractCompleteUrl() function joins wrapped URL fragments on the
165
- // host side, so the agent's rendering width is irrelevant. As bonus,
166
- // dropping COLUMNS=1000 fixes daily-chat soft-wrap weirdness in
167
- // long-form prose (agents see the real host terminal width).
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', container, agent],
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) => resolve(code ?? 0));
191
- child.on('error', () => resolve(1));
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.6: runtime owns the transport. Operator no longer creates a
4
- // `transport/` dir on their host filesystem; init creates it at
5
- // <base>/<name>/transport/ where <base> is per-OS user-mode (or
6
- // system-mode via CROSSTALK_STORAGE_MODE=system).
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
- // Operator's interaction surface:
9
- // crosstalk init # default container 'crosstalk'
10
- // crosstalk init --containername uat # named container 'uat'
11
- // crosstalk init --remote git@example:t.git # set upstream at init time
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
- // Mechanics:
14
- // 1. Resolve <base>/<name>/ and pre-create the bind-mount subdirs
15
- // (transport/, crosstalk-root/, crosstalk-state/).
16
- // 2. Run `crosstalkd init /init-target` in a one-shot container,
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.6';
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. The transport git repo lives at
46
- <base>/<name>/transport/ where <base> is:
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 at init time. Engine's first
59
- tick will fetch + rebase against this remote.
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
- // Pre-create the storage tree. Docker would auto-create the bind-mount
99
- // dirs root-owned otherwise, breaking the entrypoint's chown step.
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
- // Engine-side template scaffolding: run crosstalkd init in a one-shot
118
- // container, bind-mounting our transport/ as /init-target. --user maps
119
- // ownership to operator UID so the written files are operator-owned.
120
- const uid = typeof process.getuid === 'function' ? process.getuid() : null;
121
- const gid = typeof process.getgid === 'function' ? process.getgid() : null;
122
- const userArgs = (uid != null && gid != null) ? ['--user', `${uid}:${gid}`] : [];
123
-
124
- const dockerArgs = [
125
- 'run', '--rm',
126
- '-v', `${paths.transportDir}:/init-target`,
127
- ...userArgs,
128
- '--entrypoint', 'crosstalkd',
129
- image,
130
- 'init', '/init-target',
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
- const initRun = spawnSync('docker', dockerArgs, { stdio: 'inherit' });
134
- if (initRun.status !== 0) {
135
- process.stderr.write(`crosstalk init: docker run exited ${initRun.status}\n`);
136
- process.stderr.write(` command was: docker ${dockerArgs.join(' ')}\n`);
137
- return initRun.status ?? 1;
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
- const stderr = gr.stderr?.toString().trim() ?? '';
151
- process.stderr.write(
152
- `crosstalk init: 'git ${args.join(' ')}' failed ${stderr}\n` +
153
- ` Storage was scaffolded but not committed. Finish manually at:\n` +
154
- ` ${paths.transportDir}\n`,
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
- // Set up the upstream if requested. Failures here aren't fatal —
162
- // operator can retry with `cd <transport-dir> && git remote add`.
210
+ // ── Phase 4: Remote setup ──────────────────────────────────────────
163
211
  if (remote) {
164
- const rr = spawnSync('git', ['remote', 'add', 'origin', remote], {
212
+ const cur = spawnSync('git', ['remote', 'get-url', 'origin'], {
165
213
  cwd: paths.transportDir,
166
214
  stdio: 'pipe',
167
215
  });
168
- if (rr.status !== 0) {
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: 'git remote add origin ${remote}' failed ${rr.stderr?.toString().trim()}\n`,
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
- // Allocate an API port and persist it for the resolver.
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}' initialized:\n` +
242
+ `\nTransport '${name}' ready:\n` +
187
243
  ` storage: ${paths.storageRoot}\n` +
188
244
  ` transport: ${paths.transportDir}\n` +
189
245
  ` image: ${image}\n` +
190
- ` api port: ${port}\n` +
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;
@@ -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('docker', ['compose', '-f', paths.composeFile, 'restart'], {
19
- stdio: 'inherit',
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.6';
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
- const name = parseContainerName(argv);
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
- const name = parseContainerName(argv);
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.6",
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",