@cordfuse/crosstalk 7.0.0-alpha.5 → 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/commands/init.js CHANGED
@@ -1,42 +1,73 @@
1
- // crosstalk init <dir> — scaffold a new transport via docker.
1
+ // crosstalk init — scaffold a transport into <base>/<name>/.
2
2
  //
3
- // Doesn't go through the engine HTTP API there's no running engine
4
- // at init time. Instead, runs the engine in a one-shot container with
5
- // a bind-mount of the target directory, and lets crosstalkd's init
6
- // subcommand write the template files directly.
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
- // docker run --rm \
9
- // -v "$(realpath <dir>):/init-target" \
10
- // --user "$(id -u):$(id -g)" \
11
- // <image> \
12
- // crosstalkd init /init-target
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.
13
21
  //
14
- // The --user flag is critical: without it, the template files would
15
- // land owned by root (the container's user). Setting it to the
16
- // operator's UID/GID keeps file ownership sane.
22
+ // Each phase reports created/skipped/repaired so operators can tell
23
+ // what just happened.
17
24
 
18
- import { mkdirSync, existsSync } from 'fs';
19
- import { resolve } from 'path';
25
+ import { mkdirSync, existsSync, writeFileSync, readFileSync } from 'fs';
20
26
  import { spawnSync } from 'child_process';
21
- import { has, positionals } from '../lib/argv.js';
27
+ import { join } from 'path';
28
+ import { has, flag } from '../lib/argv.js';
29
+ import {
30
+ parseContainerName,
31
+ storagePaths,
32
+ pickFreePort,
33
+ DEFAULT_CONTAINER_NAME,
34
+ } from '../lib/resolve.js';
22
35
 
23
36
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
24
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.5';
37
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.7';
25
38
 
26
39
  function usage(exit = 0) {
27
40
  const w = exit === 0 ? process.stdout : process.stderr;
28
41
  w.write(
29
42
  `Usage:
30
- crosstalk init <dir> # scaffold a new transport at <dir>
43
+ crosstalk init [--containername <name>] [--remote <url>]
31
44
 
32
- Image: ${DEFAULT_IMAGE}
33
- Override: CROSSTALK_IMAGE=<image-tag> crosstalk init <dir>
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.
34
48
 
35
- The image must be pullable (or already present locally). For local dev
36
- before the GHCR publish pipeline ships, build the image first:
37
- docker build -t crosstalk-server:7.0.0-alpha.5 -f server/Dockerfile .
38
- Then run:
39
- CROSSTALK_IMAGE=crosstalk-server:7.0.0-alpha.5 crosstalk init mytransport
49
+ The transport git repo lives at <base>/<name>/transport/ where <base> is:
50
+ Linux: $XDG_DATA_HOME/crosstalk/ (or ~/.local/share/crosstalk/)
51
+ macOS: ~/Library/Application Support/crosstalk/
52
+ Windows: %LOCALAPPDATA%\\crosstalk\\
53
+
54
+ Set CROSSTALK_STORAGE_MODE=system to use the system-wide path (sudo/UAC
55
+ required; intended for headless multi-operator hosts).
56
+
57
+ Options:
58
+ --containername <name> Container name (default: 'crosstalk'). Must be
59
+ lowercase alphanumeric + ._- (no leading . or -).
60
+ One default + N named containers per machine.
61
+ --remote <url> Set git upstream. If a different remote is
62
+ already set, errors instead of overwriting.
63
+ --image <tag> Override the engine image (default: bundled
64
+ GHCR tag; or CROSSTALK_IMAGE env).
65
+ -c <name> Shorthand for --containername.
66
+
67
+ Examples:
68
+ crosstalk init # default container
69
+ crosstalk init --containername uat # second container
70
+ crosstalk init --remote git\\@github.com:me/t.git # with upstream
40
71
  `,
41
72
  );
42
73
  process.exit(exit);
@@ -44,62 +75,176 @@ function usage(exit = 0) {
44
75
 
45
76
  export async function run(argv) {
46
77
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
47
- const pos = positionals(argv, []);
48
- if (pos.length === 0) usage(1);
49
-
50
- const target = resolve(pos[0]);
51
- if (!existsSync(target)) mkdirSync(target, { recursive: true });
52
-
53
- // Resolve uid/gid via process.getuid() — POSIX only. On Windows the
54
- // --user flag is omitted; Docker Desktop handles ownership differently.
55
- const uid = typeof process.getuid === 'function' ? process.getuid() : null;
56
- const gid = typeof process.getgid === 'function' ? process.getgid() : null;
57
- const userArgs = (uid != null && gid != null) ? ['--user', `${uid}:${gid}`] : [];
58
-
59
- // --entrypoint crosstalkd overrides the image's default ENTRYPOINT
60
- // (which is the dispatcher entrypoint.sh requiring TRANSPORT_GIT_URL).
61
- // For init we want to call crosstalkd directly with no env requirements.
62
- const args = [
63
- 'run', '--rm',
64
- '-v', `${target}:/init-target`,
65
- ...userArgs,
66
- '--entrypoint', 'crosstalkd',
67
- DEFAULT_IMAGE,
68
- 'init', '/init-target',
69
- ];
70
78
 
71
- const r = spawnSync('docker', args, { stdio: 'inherit' });
72
- if (r.status !== 0) {
73
- process.stderr.write(`crosstalk init: docker run exited ${r.status}\n`);
74
- process.stderr.write(` command was: docker ${args.join(' ')}\n`);
75
- return r.status ?? 1;
79
+ let name;
80
+ try {
81
+ name = parseContainerName(argv);
82
+ } catch (err) {
83
+ process.stderr.write(`crosstalk init: ${err.message}\n`);
84
+ return 1;
85
+ }
86
+
87
+ const remote = flag(argv, '--remote');
88
+ const image = flag(argv, '--image') ?? DEFAULT_IMAGE;
89
+ const paths = storagePaths(name);
90
+ const report = [];
91
+
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.).
96
+ try {
97
+ mkdirSync(paths.transportDir, { recursive: true });
98
+ mkdirSync(paths.crosstalkRoot, { recursive: true });
99
+ mkdirSync(paths.crosstalkState, { recursive: true });
100
+ } catch (err) {
101
+ process.stderr.write(
102
+ `crosstalk init: failed to create storage at ${paths.storageRoot} — ${err.message}\n`,
103
+ );
104
+ if (paths.mode === 'system') {
105
+ process.stderr.write(
106
+ ` System-mode storage requires write access to ${paths.base}.\n` +
107
+ ` Try: sudo mkdir -p ${paths.storageRoot} && sudo chown -R $USER ${paths.storageRoot}\n`,
108
+ );
109
+ }
110
+ return 1;
111
+ }
112
+
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
+ }
131
+
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;
76
188
  }
77
189
 
78
- // Auto git init + initial commit so the transport is ready for
79
- // `crosstalk up` immediately. Engine expects a git repo (cursor +
80
- // commit operations on every tick). Doing this on the host so the
81
- // commit author uses the operator's local git config — not the
82
- // container's default.
83
- //
84
- // Best-effort: if any git step fails, print a warning but don't fail
85
- // the init (operator can complete the steps manually).
86
190
  const gitSteps = [
87
191
  ['init', '--quiet', '--initial-branch=main'],
88
192
  ['add', '-A'],
89
193
  ['commit', '--quiet', '-m', 'initial transport'],
90
194
  ];
195
+ let gitOk = true;
91
196
  for (const args of gitSteps) {
92
- const gr = spawnSync('git', args, { cwd: target, stdio: 'pipe' });
197
+ const gr = spawnSync('git', args, { cwd: paths.transportDir, stdio: 'pipe' });
93
198
  if (gr.status !== 0) {
94
- const stderr = gr.stderr?.toString().trim() ?? '';
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;
205
+ break;
206
+ }
207
+ }
208
+ report.push(gitOk ? ` git: initialized + committed` : ` git: already present (skipped)`);
209
+
210
+ // ── Phase 4: Remote setup ──────────────────────────────────────────
211
+ if (remote) {
212
+ const cur = spawnSync('git', ['remote', 'get-url', 'origin'], {
213
+ cwd: paths.transportDir,
214
+ stdio: 'pipe',
215
+ });
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 {
95
232
  process.stderr.write(
96
- `crosstalk init: \`git ${args.join(' ')}\` failed (transport scaffolded but not committed)\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`,
97
235
  );
98
- if (stderr) process.stderr.write(` ${stderr.split('\n').join('\n ')}\n`);
99
- process.stderr.write(` Finish manually: cd ${target} && git init && git add -A && git commit -m "initial transport"\n`);
100
- return 0; // not fatal — scaffold succeeded
236
+ return 1;
101
237
  }
102
238
  }
103
- process.stdout.write(`\nGit initialized; transport is ready for \`crosstalk up\`.\n`);
239
+
240
+ // ── Phase 5: Summary ───────────────────────────────────────────────
241
+ process.stdout.write(
242
+ `\nTransport '${name}' ready:\n` +
243
+ ` storage: ${paths.storageRoot}\n` +
244
+ ` transport: ${paths.transportDir}\n` +
245
+ ` image: ${image}\n` +
246
+ report.join('\n') + '\n' +
247
+ `\nNext: crosstalk up${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}\n`,
248
+ );
104
249
  return 0;
105
250
  }
package/commands/logs.js CHANGED
@@ -1,14 +1,14 @@
1
- // crosstalk logs — tail the engine container's logs.
1
+ // crosstalk logs — tail a transport's engine logs.
2
2
 
3
3
  import { existsSync } from 'fs';
4
4
  import { spawnSync } from 'child_process';
5
- import { requireTransportRoot, composeFile } from '../lib/transport.js';
6
5
  import { has } from '../lib/argv.js';
6
+ import { requireInitialized } from '../lib/resolve.js';
7
7
 
8
8
  function usage(exit = 0) {
9
9
  const w = exit === 0 ? process.stdout : process.stderr;
10
10
  w.write(
11
- `Usage: crosstalk logs [-f|--follow] [-n <lines>]
11
+ `Usage: crosstalk logs [--containername <name>] [-f|--follow] [-n <lines>]
12
12
 
13
13
  Streams the engine container's stdout/stderr. -f to follow (Ctrl-C to stop).
14
14
  `,
@@ -19,21 +19,19 @@ Streams the engine container's stdout/stderr. -f to follow (Ctrl-C to stop).
19
19
  export async function run(argv) {
20
20
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
21
21
 
22
- const root = requireTransportRoot();
23
- const composeYml = composeFile(root);
24
- if (!existsSync(composeYml)) {
25
- process.stderr.write(`crosstalk logs: no docker-compose.yml — run 'crosstalk up' first.\n`);
22
+ const { name, paths } = requireInitialized(argv);
23
+ if (!existsSync(paths.composeFile)) {
24
+ process.stderr.write(`crosstalk logs: '${name}' has no compose file — run 'crosstalk up' first.\n`);
26
25
  return 1;
27
26
  }
28
- const args = ['compose', '-f', composeYml, 'logs'];
27
+ const args = ['compose', '-f', paths.composeFile, 'logs'];
29
28
  if (has(argv, '-f') || has(argv, '--follow')) args.push('--follow');
30
- // Pass through tail count if provided.
31
29
  for (let i = 0; i < argv.length; i++) {
32
30
  if (argv[i] === '-n' || argv[i] === '--tail') {
33
31
  args.push('--tail', argv[i + 1] ?? '100');
34
32
  i++;
35
33
  }
36
34
  }
37
- const r = spawnSync('docker', args, { cwd: root, stdio: 'inherit' });
35
+ const r = spawnSync('docker', args, { stdio: 'inherit' });
38
36
  return r.status ?? 1;
39
37
  }
package/commands/pull.js CHANGED
@@ -1,23 +1,21 @@
1
- // crosstalk pull — refresh the engine image.
1
+ // crosstalk pull — refresh the engine image for a transport.
2
2
 
3
3
  import { existsSync } from 'fs';
4
4
  import { spawnSync } from 'child_process';
5
- import { requireTransportRoot, composeFile } from '../lib/transport.js';
6
5
  import { has } from '../lib/argv.js';
6
+ import { requireInitialized } from '../lib/resolve.js';
7
7
 
8
8
  export async function run(argv) {
9
9
  if (has(argv, '--help') || has(argv, '-h')) {
10
- process.stdout.write('Usage: crosstalk pull\n');
10
+ process.stdout.write('Usage: crosstalk pull [--containername <name>]\n');
11
11
  return 0;
12
12
  }
13
- const root = requireTransportRoot();
14
- const composeYml = composeFile(root);
15
- if (!existsSync(composeYml)) {
16
- process.stderr.write(`crosstalk pull: no docker-compose.yml — run 'crosstalk up' first.\n`);
13
+ const { name, paths } = requireInitialized(argv);
14
+ if (!existsSync(paths.composeFile)) {
15
+ process.stderr.write(`crosstalk pull: '${name}' has no compose file — run 'crosstalk up' first.\n`);
17
16
  return 1;
18
17
  }
19
- const r = spawnSync('docker', ['compose', '-f', composeYml, 'pull'], {
20
- cwd: root,
18
+ const r = spawnSync('docker', ['compose', '-f', paths.composeFile, 'pull'], {
21
19
  stdio: 'inherit',
22
20
  });
23
21
  return r.status ?? 1;
@@ -3,19 +3,20 @@
3
3
  // Exit 0 if all targets have REPLIED or FAILED, exit 2 while any PENDING
4
4
  // — agents can poll cheaply in a loop. Mirrors crosstalkd replies.
5
5
 
6
- import { api } from '../lib/api-client.js';
6
+ import { apiFor } from '../lib/api-client.js';
7
7
  import { reportAndExit } from '../lib/errors.js';
8
8
  import { positionals, has } from '../lib/argv.js';
9
9
 
10
10
  function usage(exit = 0) {
11
11
  const w = exit === 0 ? process.stdout : process.stderr;
12
- w.write('Usage: crosstalk replies <relPath> [<relPath>...]\n');
12
+ w.write('Usage: crosstalk replies [--containername <name>] <relPath> [<relPath>...]\n');
13
13
  process.exit(exit);
14
14
  }
15
15
 
16
16
  export async function run(argv) {
17
17
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
18
- const targets = positionals(argv, []);
18
+ const api = apiFor(argv);
19
+ const targets = positionals(argv, ['--containername', '-c']);
19
20
  if (targets.length === 0) usage(1);
20
21
 
21
22
  let resp;
@@ -1,24 +1,29 @@
1
- // crosstalk restart — restart the engine container.
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';
5
- import { requireTransportRoot, composeFile } from '../lib/transport.js';
6
10
  import { has } from '../lib/argv.js';
11
+ import { requireInitialized } from '../lib/resolve.js';
7
12
 
8
13
  export async function run(argv) {
9
14
  if (has(argv, '--help') || has(argv, '-h')) {
10
- process.stdout.write('Usage: crosstalk restart\n');
15
+ process.stdout.write('Usage: crosstalk restart [--containername <name>]\n');
11
16
  return 0;
12
17
  }
13
- const root = requireTransportRoot();
14
- const composeYml = composeFile(root);
15
- if (!existsSync(composeYml)) {
16
- process.stderr.write(`crosstalk restart: no docker-compose.yml — run 'crosstalk up' first.\n`);
18
+ const { name, paths } = requireInitialized(argv);
19
+ if (!existsSync(paths.composeFile)) {
20
+ process.stderr.write(`crosstalk restart: '${name}' has no compose file — run 'crosstalk up' first.\n`);
17
21
  return 1;
18
22
  }
19
- const r = spawnSync('docker', ['compose', '-f', composeYml, 'restart'], {
20
- cwd: root,
21
- stdio: 'inherit',
22
- });
23
+ const r = spawnSync(
24
+ 'docker',
25
+ ['compose', '-f', paths.composeFile, 'up', '-d', '--force-recreate'],
26
+ { stdio: 'inherit' },
27
+ );
23
28
  return r.status ?? 1;
24
29
  }
package/commands/rm.js ADDED
@@ -0,0 +1,109 @@
1
+ // crosstalk rm — remove a transport completely.
2
+ //
3
+ // Stops the container if running, removes the container, deletes the
4
+ // entire <base>/<name>/ tree (transport git repo, installed CLIs, auth
5
+ // state, dispatcher state, compose file, port file). Confirmation
6
+ // prompt by default; --force / -f to skip for scripted use.
7
+ //
8
+ // Cannot undo. All message history is lost.
9
+
10
+ import { existsSync, rmSync } from 'fs';
11
+ import { spawnSync } from 'child_process';
12
+ import { createInterface } from 'readline';
13
+ import { has } from '../lib/argv.js';
14
+ import {
15
+ parseContainerName,
16
+ storagePaths,
17
+ isInitialized,
18
+ isRunning,
19
+ containerExists,
20
+ DEFAULT_CONTAINER_NAME,
21
+ } from '../lib/resolve.js';
22
+
23
+ function usage(exit = 0) {
24
+ const w = exit === 0 ? process.stdout : process.stderr;
25
+ w.write(
26
+ `Usage: crosstalk rm [--containername <name>] [--force]
27
+
28
+ Stops and removes the engine container, then deletes the entire
29
+ storage tree under <base>/<name>/. Includes the transport git repo
30
+ (all message history), installed agent CLIs + auth tokens, and
31
+ dispatcher state. CANNOT BE UNDONE.
32
+
33
+ Options:
34
+ --containername <name> Container to remove (default: 'crosstalk').
35
+ --force, -f Skip the confirmation prompt.
36
+ `,
37
+ );
38
+ process.exit(exit);
39
+ }
40
+
41
+ function confirm(promptText) {
42
+ return new Promise((resolve) => {
43
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
44
+ rl.question(promptText, (ans) => {
45
+ rl.close();
46
+ const yes = /^y(es)?$/i.test(ans.trim());
47
+ resolve(yes);
48
+ });
49
+ });
50
+ }
51
+
52
+ export async function run(argv) {
53
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
54
+
55
+ let name;
56
+ try {
57
+ name = parseContainerName(argv);
58
+ } catch (err) {
59
+ process.stderr.write(`crosstalk rm: ${err.message}\n`);
60
+ return 1;
61
+ }
62
+
63
+ const paths = storagePaths(name);
64
+ const haveStorage = isInitialized(name) || existsSync(paths.storageRoot);
65
+ const haveContainer = containerExists(name);
66
+
67
+ if (!haveStorage && !haveContainer) {
68
+ process.stderr.write(`crosstalk rm: transport '${name}' not found.\n`);
69
+ return 1;
70
+ }
71
+
72
+ if (!has(argv, '--force') && !has(argv, '-f')) {
73
+ process.stderr.write(
74
+ `Remove '${name}'?\n` +
75
+ ` This deletes:\n` +
76
+ ` - transport git repo at ${paths.transportDir}\n` +
77
+ ` (all message history, channels, replies)\n` +
78
+ ` - installed agent CLIs + auth tokens (claude, codex, ...)\n` +
79
+ ` - dispatcher state (cursor, heartbeat, errors.log)\n` +
80
+ ` Cannot undo.\n`,
81
+ );
82
+ const ok = await confirm('[y/N]: ');
83
+ if (!ok) {
84
+ process.stderr.write('crosstalk rm: cancelled.\n');
85
+ return 1;
86
+ }
87
+ }
88
+
89
+ // Stop + remove the container (best-effort).
90
+ if (isRunning(name) && existsSync(paths.composeFile)) {
91
+ spawnSync('docker', ['compose', '-f', paths.composeFile, 'down'], { stdio: 'inherit' });
92
+ } else if (haveContainer) {
93
+ spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
94
+ }
95
+
96
+ // Wipe storage.
97
+ try {
98
+ rmSync(paths.storageRoot, { recursive: true, force: true });
99
+ } catch (err) {
100
+ process.stderr.write(
101
+ `crosstalk rm: failed to remove ${paths.storageRoot} — ${err.message}\n` +
102
+ ` (container is removed, but storage cleanup needs intervention)\n`,
103
+ );
104
+ return 1;
105
+ }
106
+
107
+ process.stdout.write(`Removed '${name}'.\n`);
108
+ return 0;
109
+ }
package/commands/run.js CHANGED
@@ -8,11 +8,11 @@
8
8
  // [--new] <file|->
9
9
 
10
10
  import { readFileSync, existsSync, statSync } from 'fs';
11
- import { api } from '../lib/api-client.js';
11
+ import { apiFor } from '../lib/api-client.js';
12
12
  import { reportAndExit } from '../lib/errors.js';
13
13
  import { flag, has } from '../lib/argv.js';
14
14
 
15
- const FLAGS_WITH_VALUE = new Set(['--type', '--to', '--as', '--fanout', '--channel', '--from']);
15
+ const FLAGS_WITH_VALUE = new Set(['--type', '--to', '--as', '--fanout', '--channel', '--from', '--containername', '-c']);
16
16
 
17
17
  function usage(exit = 0) {
18
18
  const w = exit === 0 ? process.stdout : process.stderr;
@@ -52,6 +52,7 @@ function resolveBody(arg) {
52
52
  export async function run(argv) {
53
53
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
54
54
 
55
+ const api = apiFor(argv);
55
56
  const type = flag(argv, '--type');
56
57
  if (type !== 'primitive' && type !== 'workflow') usage(1);
57
58
 
@@ -60,7 +61,7 @@ export async function run(argv) {
60
61
  let bodyArg;
61
62
  for (let i = argv.length - 1; i >= 0; i--) {
62
63
  const a = argv[i];
63
- if (a.startsWith('--')) break;
64
+ if (a.startsWith('-')) break;
64
65
  // Make sure we're not the value of a flag like `--to <value>`
65
66
  const prev = argv[i - 1];
66
67
  if (prev && FLAGS_WITH_VALUE.has(prev)) continue;
@@ -1,9 +1,16 @@
1
1
  // crosstalk status — mirror of crosstalkd status output, fetched via API.
2
+ //
3
+ // alpha.6: also prints the host path of the transport git repo as an
4
+ // escape hatch for operators who want to poke around with raw git.
2
5
 
3
- import { api } from '../lib/api-client.js';
6
+ import { apiFor } from '../lib/api-client.js';
4
7
  import { reportAndExit } from '../lib/errors.js';
8
+ import { requireInitialized } from '../lib/resolve.js';
9
+
10
+ export async function run(argv) {
11
+ const { name, paths } = requireInitialized(argv);
12
+ const api = apiFor(argv);
5
13
 
6
- export async function run(_argv) {
7
14
  let s;
8
15
  try {
9
16
  s = await api.get('/status');
@@ -11,25 +18,23 @@ export async function run(_argv) {
11
18
  reportAndExit(err, 'crosstalk status');
12
19
  }
13
20
 
14
- process.stdout.write(`Crosstalk transport: ${s.transport_root}\n`);
21
+ process.stdout.write(`Container: ${name}\n`);
22
+ process.stdout.write(`Transport host path: ${paths.transportDir}\n`);
15
23
  process.stdout.write(`Engine alias: ${s.alias}\n`);
16
24
  process.stdout.write(`Engine version: ${s.version}\n`);
17
25
  process.stdout.write('\n');
18
26
 
19
27
  process.stdout.write(`Claimed models: ${s.claimed_models.length}\n`);
20
28
  if (s.claimed_models.length === 0) {
21
- // Two failure shapes look the same in claimed_models=0: (a) no CLIs
22
- // installed at all, (b) CLIs installed but data/models.yaml has no
23
- // entries referencing them. Ask /agents/installed to distinguish so
24
- // the operator gets a directly-actionable hint.
25
29
  let installed = null;
26
30
  try {
27
31
  const r = await api.get('/agents/installed');
28
32
  installed = r.installed;
29
33
  } catch { /* ignore — fall back to generic message */ }
30
34
  if (installed && installed.length > 0) {
35
+ const verb = installed.length === 1 ? 'references it' : 'references them';
31
36
  process.stdout.write(
32
- ` (${installed.join(', ')} installed in the container but no data/models.yaml entry references them — add one to claim)\n`,
37
+ ` (${installed.join(', ')} installed in the container but no data/models.yaml entry ${verb} — add one to claim)\n`,
33
38
  );
34
39
  } else {
35
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');
@@ -56,9 +61,9 @@ export async function run(_argv) {
56
61
 
57
62
  process.stdout.write(`Channels: ${s.channels.length}\n`);
58
63
  for (const ch of s.channels) {
59
- const name = ch.name ?? '(unnamed)';
64
+ const chName = ch.name ?? '(unnamed)';
60
65
  const parentSuffix = ch.parent ? ` [child of ${ch.parent.slice(0, 8)}]` : '';
61
- process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${name}${parentSuffix}\n`);
66
+ process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${chName}${parentSuffix}\n`);
62
67
  }
63
68
  process.stdout.write('\n');
64
69