@cordfuse/crosstalk 7.0.0-alpha.6 → 7.0.0-alpha.8

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/commands/chat.js CHANGED
@@ -1,110 +1,33 @@
1
- // crosstalk chat — the single interactive entry point into the container.
1
+ // crosstalk chat — interactive entry into the container.
2
2
  //
3
- // Modes:
4
- // crosstalk chat --agent <name> # interactive with a specific agent
5
- // crosstalk chat --agent <name> --login # OAuth setup flow with browser-open
6
- // crosstalk chat --shell # bash escape hatch (sysadmin / debug)
3
+ // Modes (alpha.8):
4
+ // crosstalk chat --agent <name> # interactive session with an agent CLI
5
+ // crosstalk chat --shell # bash escape hatch (debug, install CLIs, run setup commands)
7
6
  //
8
- // --agent is required for non-shell modes. There's no implicit default
9
- // (containers may have multiple agent CLIs installed; the operator
10
- // must say which one to invoke).
7
+ // --agent is required for non-shell mode. There's no implicit default;
8
+ // containers may have multiple agent CLIs installed, the operator
9
+ // picks one explicitly.
11
10
  //
12
- // Login flow design (root-cause fix, not a bandaid):
13
- // The container has no DISPLAY / no browser / no xdg-open. So the agent
14
- // CLI prints a URL and expects the operator to manually open it. THIS
15
- // wrapper runs on the HOST where the browser lives. It intercepts the
16
- // URL on the agent's stdout and opens it directly via the OS-native
17
- // browser launcher (`open` / `xdg-open` / `wslview` / `start`). Operator
18
- // never has to read or copy-paste the URL.
19
- //
20
- // COLUMNS=1000 is still set under --login as a contingency: if browser
21
- // open fails (headless SSH session, missing xdg-open, etc.), the URL
22
- // still renders unwrapped in the terminal for manual copy. That's the
23
- // fallback, not the primary mechanism.
11
+ // `--login` was removed in alpha.8. Per-agent auth is now handled via
12
+ // `data/crosstalk.yaml`'s per-provider `env_file:` (env-injected at
13
+ // agent spawn time inside the engine) plus operator-run setup commands
14
+ // for agents whose auth is a stateful CLI act (e.g. codex). Recipes are
15
+ // documented in transport/auth/README.md.
24
16
 
25
- import { spawn, spawnSync } from 'child_process';
17
+ import { spawnSync } from 'child_process';
26
18
  import { requireInitialized } from '../lib/resolve.js';
27
19
  import { apiFor, ConnectError } from '../lib/api-client.js';
28
20
  import { flag, has } from '../lib/argv.js';
29
21
 
30
22
  const KNOWN_AGENTS = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
31
23
 
32
- // URL extraction that survives agent TUI rendering. Naive regex against
33
- // raw stdout fails because agent CLIs (Claude in particular) draw a box
34
- // around the URL with line wraps every ~N chars; the captured URL is
35
- // truncated at the first wrap, with ANSI control chars leaking in at
36
- // the cut point. Real example from alpha.3 macOS test: extracted URL
37
- // ended with `%18%6b…` (CAN char + box-drawing artifact) instead of
38
- // the `redirect_uri=…` param, breaking OAuth.
39
- //
40
- // Approach:
41
- // 1. ANSI-strip everything (CSI / OSC / SS3 escapes).
42
- // 2. Strip control chars (0x00-0x1F except LF, plus DEL).
43
- // 3. Join line wraps that occur INSIDE a URL-safe character run —
44
- // `<url-char>\n<url-char>` → `<url-char><url-char>`. Preserves real
45
- // `<url-char>\n<non-url-char>` boundaries.
46
- // 4. Match `https?://<url-chars>+` against the joined buffer.
47
- // 5. Only fire when the character AFTER the matched URL is a true
48
- // terminator (whitespace, end-of-buffer) — guards against acting
49
- // on a partial URL still being written.
50
-
51
- const URL_SAFE_CLASS = "a-zA-Z0-9%/?#&=+\\-._~:@!$()*,;";
52
- const URL_RE = new RegExp(`https?://[${URL_SAFE_CLASS}]+`);
53
-
54
- function extractCompleteUrl(rawBuffer) {
55
- // Strip ANSI CSI escapes (\x1B[ ... terminator).
56
- let clean = rawBuffer.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, '');
57
- // Strip ANSI OSC escapes (\x1B] ... BEL or ST).
58
- clean = clean.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
59
- // Strip SS3 escapes (\x1BO + one char).
60
- clean = clean.replace(/\x1BO./g, '');
61
- // Strip remaining single-char escapes that slipped through.
62
- clean = clean.replace(/\x1B[^a-zA-Z0-9]/g, '');
63
- // Strip control chars except LF (0x0A); we use LF for line-wrap rejoin.
64
- clean = clean.replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
65
- // Rejoin URL-character wrap points: `<url-char>\n<url-char>` becomes
66
- // `<url-char><url-char>`. Doesn't merge `<url-char>\n<space>` etc.
67
- const safeRe = new RegExp(`[${URL_SAFE_CLASS}]\\n[${URL_SAFE_CLASS}]`, 'g');
68
- let prev;
69
- do {
70
- prev = clean;
71
- clean = clean.replace(safeRe, (m) => m[0] + m[2]);
72
- } while (clean !== prev);
73
-
74
- const match = clean.match(URL_RE);
75
- if (!match) return null;
76
- // Require a terminator after the URL — without it, the URL may still
77
- // be growing in subsequent stdout chunks. Terminator = whitespace,
78
- // quote/angle, or end-of-buffer-with-trailing-newline.
79
- const after = clean.slice(match.index + match[0].length);
80
- if (after.length === 0) return null; // EOF-as-grow case, wait
81
- if (!/^[\s<>"']/.test(after)) return null; // URL still continuing
82
- return match[0];
83
- }
84
-
85
- function openInBrowser(url) {
86
- const cmd =
87
- process.platform === 'darwin' ? 'open' :
88
- process.platform === 'win32' ? 'start' :
89
- process.env['WSL_DISTRO_NAME'] ? 'wslview' :
90
- /* default Linux */ 'xdg-open';
91
- try {
92
- const r = spawnSync(cmd, [url], { stdio: 'ignore' });
93
- return r.status === 0;
94
- } catch {
95
- return false;
96
- }
97
- }
98
-
99
24
  function usage(exit = 0) {
100
25
  const w = exit === 0 ? process.stdout : process.stderr;
101
26
  w.write(
102
- `Usage: crosstalk chat --agent <name> [--login]
27
+ `Usage: crosstalk chat --agent <name>
103
28
  crosstalk chat --shell
104
29
 
105
- Opens an interactive session inside the engine container — the single
106
- PTY-wrapped entry point for everything you'd do inside the container
107
- (daily chat, OAuth setup, sysadmin).
30
+ Opens an interactive session inside the engine container.
108
31
 
109
32
  --agent is REQUIRED for any chat mode. Containers can have multiple
110
33
  agent CLIs installed; the operator must say which one to invoke.
@@ -112,20 +35,16 @@ agent CLIs installed; the operator must say which one to invoke.
112
35
  Supported agents: ${KNOWN_AGENTS.join(', ')}.
113
36
 
114
37
  Modes:
115
- --agent <name> Interactive with the named agent.
116
- --agent <name> --login First-time auth flow. Intercepts the OAuth
117
- URL on the agent's stdout and opens it in your
118
- default browser via the OS-native launcher.
119
- Falls back to printing the URL unwrapped if no
120
- browser is reachable (headless / SSH session).
121
- --shell Drop into bash (install agent CLIs, debug,
122
- inspect state). No --agent needed.
38
+ --agent <name> Interactive with the named agent CLI. Use the
39
+ agent's own keys/commands for first-time setup
40
+ (see transport/auth/README.md for recipes).
41
+ --shell Drop into bash (install agent CLIs, debug, run
42
+ one-time auth setup commands like
43
+ \`codex login --with-api-key\`).
123
44
 
124
45
  Examples:
125
46
  crosstalk chat --agent claude # interactive claude
126
47
  crosstalk chat --agent gemini # interactive gemini
127
- crosstalk chat --agent claude --login # OAuth, claude
128
- crosstalk chat --agent codex --login # OAuth, codex
129
48
  crosstalk chat --shell # bash
130
49
  `,
131
50
  );
@@ -135,8 +54,8 @@ Examples:
135
54
  function runInteractive(container, command, env = {}) {
136
55
  // Full TTY passthrough — docker exec -it gives the container a real
137
56
  // PTY, and stdio: 'inherit' forwards stdin/stdout/stderr from the
138
- // operator's terminal. Agent CLIs get to render their rich TUI
139
- // normally.
57
+ // operator's terminal. Agent CLIs render their rich TUI normally and
58
+ // SIGWINCH propagates from the host's tty to the container's PTY.
140
59
  const envFlags = [];
141
60
  for (const [k, v] of Object.entries(env)) {
142
61
  envFlags.push('--env', `${k}=${v}`);
@@ -147,51 +66,6 @@ function runInteractive(container, command, env = {}) {
147
66
  return r.status ?? 1;
148
67
  }
149
68
 
150
- 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).
168
- return new Promise((resolve) => {
169
- const child = spawn(
170
- 'docker',
171
- ['exec', '-it', container, agent],
172
- { stdio: ['inherit', 'pipe', 'inherit'] },
173
- );
174
- let opened = false;
175
- let buffer = '';
176
- child.stdout.on('data', (chunk) => {
177
- process.stdout.write(chunk);
178
- if (opened) return;
179
- buffer += chunk.toString();
180
- const url = extractCompleteUrl(buffer);
181
- if (!url) return;
182
- opened = true;
183
- const ok = openInBrowser(url);
184
- process.stdout.write(
185
- ok
186
- ? `\n[crosstalk chat] Opened auth URL in your default browser.\n`
187
- : `\n[crosstalk chat] Could not auto-open browser. Open this URL manually:\n ${url}\n`,
188
- );
189
- });
190
- child.on('exit', (code) => resolve(code ?? 0));
191
- child.on('error', () => resolve(1));
192
- });
193
- }
194
-
195
69
  export async function run(argv) {
196
70
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
197
71
 
@@ -241,9 +115,5 @@ export async function run(argv) {
241
115
  return 1;
242
116
  }
243
117
 
244
- if (has(argv, '--login')) {
245
- return await runLogin(name, agent);
246
- }
247
-
248
118
  return runInteractive(name, agent);
249
119
  }
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.8';
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,133 @@ 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
+ // ── Phase 2: Transport scaffold ────────────────────────────────────
133
+ // The template-scaffold check is "does CROSSTALK-VERSION exist?" NOT
134
+ // ".git exists" — git init is a separate phase, and the engine refuses
135
+ // to re-scaffold over an existing CROSSTALK-VERSION (without --force).
136
+ // Decoupling means a disrupted init (template written, git init never
137
+ // ran) re-enters Phase 3 cleanly on re-run.
138
+ const transportGitDir = join(paths.transportDir, '.git');
139
+ const crosstalkVersionFile = join(paths.transportDir, 'CROSSTALK-VERSION');
140
+ if (!existsSync(crosstalkVersionFile)) {
141
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
142
+ const gid = typeof process.getgid === 'function' ? process.getgid() : null;
143
+ const userArgs = (uid != null && gid != null) ? ['--user', `${uid}:${gid}`] : [];
144
+
145
+ const dockerArgs = [
146
+ 'run', '--rm',
147
+ '-v', `${paths.transportDir}:/init-target`,
148
+ ...userArgs,
149
+ '--entrypoint', 'crosstalkd',
150
+ image,
151
+ 'init', '/init-target',
152
+ ];
153
+
154
+ // Bug B fix: don't inherit stdio — the engine prints next-steps that
155
+ // confuse operators ("Transport initialized at /init-target" with
156
+ // container-internal paths). Capture, show only on failure.
157
+ const initRun = spawnSync('docker', dockerArgs, { stdio: 'pipe' });
158
+ if (initRun.status !== 0) {
159
+ const stderr = initRun.stderr?.toString() ?? '';
160
+ const stdout = initRun.stdout?.toString() ?? '';
161
+ process.stderr.write(`crosstalk init: docker run exited ${initRun.status}\n`);
162
+ if (stderr.trim()) process.stderr.write(` stderr: ${stderr.trim()}\n`);
163
+ if (stdout.trim()) process.stderr.write(` stdout: ${stdout.trim()}\n`);
164
+ process.stderr.write(` command was: docker ${dockerArgs.join(' ')}\n`);
165
+ return initRun.status ?? 1;
166
+ }
167
+ report.push(` template: scaffolded via ${image}`);
168
+ } else {
169
+ report.push(` template: present (skipped scaffold)`);
170
+ }
171
+
172
+ // ── Phase 3: Git init + initial commit ─────────────────────────────
173
+ if (!existsSync(crosstalkVersionFile)) {
174
+ // Engine init succeeded but didn't leave files. Bail clearly — we
175
+ // won't proceed to git init on an empty dir.
176
+ process.stderr.write(
177
+ `crosstalk init: transport template missing at ${paths.transportDir}\n` +
178
+ ` Engine init didn't write template files. Try 'crosstalk rm' then re-init.\n`,
179
+ );
180
+ return 1;
138
181
  }
139
182
 
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
183
  const gitSteps = [
143
184
  ['init', '--quiet', '--initial-branch=main'],
144
185
  ['add', '-A'],
145
186
  ['commit', '--quiet', '-m', 'initial transport'],
146
187
  ];
188
+ let gitOk = true;
147
189
  for (const args of gitSteps) {
148
190
  const gr = spawnSync('git', args, { cwd: paths.transportDir, stdio: 'pipe' });
149
191
  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
192
+ // 'git init' on existing repo is fine (it re-runs idempotently);
193
+ // 'add -A' is also idempotent. 'commit' fails when there's
194
+ // nothing to commit that means a previous run already committed.
195
+ // Only the first call (git init) is load-bearing; the rest are
196
+ // best-effort to populate the initial state.
197
+ gitOk = false;
157
198
  break;
158
199
  }
159
200
  }
201
+ report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
160
202
 
161
- // Set up the upstream if requested. Failures here aren't fatal —
162
- // operator can retry with `cd <transport-dir> && git remote add`.
203
+ // ── Phase 4: Remote setup ──────────────────────────────────────────
163
204
  if (remote) {
164
- const rr = spawnSync('git', ['remote', 'add', 'origin', remote], {
205
+ const cur = spawnSync('git', ['remote', 'get-url', 'origin'], {
165
206
  cwd: paths.transportDir,
166
207
  stdio: 'pipe',
167
208
  });
168
- if (rr.status !== 0) {
209
+ const currentUrl = cur.status === 0 ? cur.stdout.toString().trim() : null;
210
+ if (!currentUrl) {
211
+ const rr = spawnSync('git', ['remote', 'add', 'origin', remote], {
212
+ cwd: paths.transportDir,
213
+ stdio: 'pipe',
214
+ });
215
+ if (rr.status !== 0) {
216
+ process.stderr.write(
217
+ `crosstalk init: 'git remote add origin ${remote}' failed — ${rr.stderr?.toString().trim()}\n`,
218
+ );
219
+ } else {
220
+ report.push(` remote: ${remote} (added)`);
221
+ }
222
+ } else if (currentUrl === remote) {
223
+ report.push(` remote: ${remote} (already set)`);
224
+ } else {
169
225
  process.stderr.write(
170
- `crosstalk init: 'git remote add origin ${remote}' failed ${rr.stderr?.toString().trim()}\n`,
226
+ `crosstalk init: remote already set to '${currentUrl}'; refusing to overwrite with '${remote}'.\n` +
227
+ ` Use 'crosstalk status' to see the transport path; cd there and adjust git config manually if intended.\n`,
171
228
  );
229
+ return 1;
172
230
  }
173
231
  }
174
232
 
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
-
233
+ // ── Phase 5: Summary ───────────────────────────────────────────────
185
234
  process.stdout.write(
186
- `\nTransport '${name}' initialized:\n` +
235
+ `\nTransport '${name}' ready:\n` +
187
236
  ` storage: ${paths.storageRoot}\n` +
188
237
  ` transport: ${paths.transportDir}\n` +
189
238
  ` image: ${image}\n` +
190
- ` api port: ${port}\n` +
191
- (remote ? ` remote: ${remote}\n` : '') +
239
+ report.join('\n') + '\n' +
192
240
  `\nNext: crosstalk up${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}\n`,
193
241
  );
194
242
  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
  }
@@ -34,10 +34,10 @@ export async function run(argv) {
34
34
  if (installed && installed.length > 0) {
35
35
  const verb = installed.length === 1 ? 'references it' : 'references them';
36
36
  process.stdout.write(
37
- ` (${installed.join(', ')} installed in the container but no data/models.yaml entry ${verb} — add one to claim)\n`,
37
+ ` (${installed.join(', ')} installed in the container but no data/crosstalk.yaml entry ${verb} — add one to claim)\n`,
38
38
  );
39
39
  } else {
40
- process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/models.yaml)\n');
40
+ process.stdout.write(' (no model CLIs found inside the container — install one via `crosstalk chat --shell` and add an entry to data/crosstalk.yaml)\n');
41
41
  }
42
42
  } else {
43
43
  for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
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.8';
28
28
 
29
29
  function usage(exit = 0) {
30
30
  const w = exit === 0 ? process.stdout : process.stderr;
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
@@ -168,8 +168,18 @@ export function containerExists(name) {
168
168
  // Look up the resolved name+paths from argv, validate that init has run,
169
169
  // and exit with a clear error if not. For verbs that operate on existing
170
170
  // transports (up after init, status, chat, down, rm, etc).
171
+ //
172
+ // Validation errors from parseContainerName are caught here and printed
173
+ // cleanly — Mac's bug C from alpha.6 was a raw stack trace when an
174
+ // invalid --containername reached the resolver.
171
175
  export function requireInitialized(argv) {
172
- const name = parseContainerName(argv);
176
+ let name;
177
+ try {
178
+ name = parseContainerName(argv);
179
+ } catch (err) {
180
+ process.stderr.write(`crosstalk: ${err.message}\n`);
181
+ process.exit(1);
182
+ }
173
183
  if (!isInitialized(name)) {
174
184
  process.stderr.write(
175
185
  `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.8",
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",