@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/up.js CHANGED
@@ -1,140 +1,76 @@
1
- // crosstalk up — bring up the engine container for this transport.
1
+ // crosstalk up — bring up the engine container for a transport.
2
2
  //
3
- // First-time use: generates docker-compose.yml in the transport root from
4
- // a template (operator can edit freely, subsequent `up`s won't clobber).
5
- // The compose file bind-mounts the transport root as the engine's working
6
- // tree so git operations work both ways — engine sees operator's local
7
- // commits and configured remotes immediately, operator sees engine
8
- // commits (replies, cursor advances) immediately.
3
+ // alpha.6: no cwd magic. Resolves container by --containername flag
4
+ // (default 'crosstalk') and operates against <base>/<name>/.
9
5
  //
10
- // Subsequent runs: just shell out to `docker compose up -d`.
6
+ // Three lifecycle cases:
7
+ // 1. Container already running → error (use chat/restart instead).
8
+ // 2. Storage exists, no container → resume; print one-line notice.
9
+ // 3. Storage doesn't exist → error pointing to `crosstalk init`.
10
+ //
11
+ // First-time up generates <base>/<name>/docker-compose.yml from a template.
12
+ // Subsequent ups respect operator edits — only regenerate if missing.
11
13
 
12
- import { writeFileSync, existsSync, mkdirSync, appendFileSync, readFileSync } from 'fs';
14
+ import { writeFileSync, existsSync, readFileSync } from 'fs';
13
15
  import { spawnSync } from 'child_process';
14
- import { join } from 'path';
15
- import { homedir } from 'os';
16
- import { requireTransportRoot, transportName, composeFile } from '../lib/transport.js';
17
16
  import { has } from '../lib/argv.js';
17
+ import {
18
+ requireInitialized,
19
+ isRunning,
20
+ containerExists,
21
+ apiPortFor,
22
+ DEFAULT_CONTAINER_NAME,
23
+ CROSSTALK_LABEL,
24
+ } from '../lib/resolve.js';
18
25
 
19
26
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
20
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.5';
21
- const DEFAULT_API_PORT = Number(process.env.CROSSTALK_API_PORT) || 7000;
22
- const STORAGE_MODE = (process.env.CROSSTALK_STORAGE_MODE ?? 'user').toLowerCase();
27
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.7';
23
28
 
24
29
  function usage(exit = 0) {
25
30
  const w = exit === 0 ? process.stdout : process.stderr;
26
31
  w.write(
27
- `Usage: crosstalk up
32
+ `Usage: crosstalk up [--containername <name>]
33
+
34
+ Starts the engine container for a transport that has already been
35
+ 'crosstalk init'd. Default container is 'crosstalk'; pass --containername
36
+ to address a named one.
28
37
 
29
- Brings up the engine container for the transport in the current directory
30
- (or any ancestor containing CROSSTALK-VERSION). First-time use generates
31
- docker-compose.yml in the transport root.
38
+ If the storage at <base>/<name>/ already exists (operator previously
39
+ brought it up then 'crosstalk down'd it), the container resumes against
40
+ that state installed agent CLIs, OAuth tokens, dispatcher cursor all
41
+ carry over.
32
42
 
33
43
  Environment:
34
- CROSSTALK_IMAGE Image to pin (default: bundled GHCR tag)
35
- CROSSTALK_API_PORT Host port to map (default: 7000)
36
- CROSSTALK_ALIAS Dispatcher's machine identity in the bus
37
- CROSSTALK_STORAGE_MODE user (default) | system
38
- Where bind-mounted crosstalk-root +
39
- crosstalk-state live on the host. 'system'
40
- uses paths under /var/lib (Linux) / /Library
41
- (macOS) / C:\\ProgramData (Windows) — requires
42
- sudo/UAC and (on macOS) Docker Desktop file-
43
- sharing allowlist. 'user' uses paths under
44
- the operator's home directory with no
45
- escalation.
44
+ CROSSTALK_IMAGE Override the engine image
45
+ CROSSTALK_ALIAS Override the engine's machine identity
46
+ (defaults to the container name)
46
47
  `,
47
48
  );
48
49
  process.exit(exit);
49
50
  }
50
51
 
51
- // Resolve the host directories that back the container's /crosstalk-root
52
- // (operator-installed CLIs + auth) and /var/lib/crosstalk-state
53
- // (dispatcher state) bind mounts.
54
- //
55
- // User-mode (default) keeps everything under the operator's home so no
56
- // sudo / UAC / Docker Desktop file-sharing allowlist gymnastics are
57
- // needed. System-mode is for headless production-style deployments
58
- // where multiple operators share a machine and a service user manages
59
- // the daemon.
60
- //
61
- // Host-side dir names use the same `crosstalk-` prefix as the container
62
- // paths so things stay identifiable wherever they surface (backup tools,
63
- // file managers, support-email screenshots).
64
- function resolveStoragePaths(transportName, mode) {
65
- const platform = process.platform;
66
- // Per-OS base directories.
67
- const bases = (platform === 'darwin')
68
- ? {
69
- user: join(homedir(), 'Library', 'Application Support', 'crosstalk'),
70
- system: '/Library/Application Support/crosstalk',
71
- }
72
- : (platform === 'win32')
73
- ? {
74
- // %LOCALAPPDATA% should always be set on supported Win versions;
75
- // fall back to ~/AppData/Local if it isn't.
76
- user: process.env['LOCALAPPDATA']
77
- ? join(process.env['LOCALAPPDATA'], 'crosstalk')
78
- : join(homedir(), 'AppData', 'Local', 'crosstalk'),
79
- system: 'C:\\ProgramData\\crosstalk',
80
- }
81
- : /* linux + other unix */ {
82
- // Honor XDG_DATA_HOME silently (no docs surface; the 99% of
83
- // operators who haven't set it get ~/.local/share/crosstalk
84
- // either way).
85
- user: join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'crosstalk'),
86
- system: '/var/lib/crosstalk',
87
- };
88
-
89
- if (mode !== 'user' && mode !== 'system') {
90
- throw new Error(
91
- `crosstalk up: CROSSTALK_STORAGE_MODE='${mode}' invalid — must be 'user' or 'system'.`,
92
- );
93
- }
94
- const base = bases[mode];
95
- const transportRoot = join(base, transportName);
96
- return {
97
- crosstalkRoot: join(transportRoot, 'crosstalk-root'),
98
- crosstalkState: join(transportRoot, 'crosstalk-state'),
99
- mode,
100
- };
101
- }
102
-
103
- function renderCompose({ name, image, apiPort, alias, uid, gid, crosstalkRootHost, crosstalkStateHost, storageMode }) {
104
- // Entrypoint runs as root for setup (npm prefix, SSH key import). It
105
- // then adjusts a 'crosstalkd' user to match CROSSTALK_UID/GID
106
- // (operator's host UID), chowns the bind-mounted transport, state
107
- // dir, AND /crosstalk-root to that user, then drops via setpriv before
108
- // exec'ing the dispatcher. Net effect: bind-mount files written by
109
- // the engine are operator-owned on the host, so \`git remote add\`
110
- // and \`rm -rf\` from the host both work without sudo.
111
- return `# Generated by \`crosstalk up\`. Safe to edit; subsequent \`up\`s
112
- # only regenerate when this file is missing. Compose config is machine-
113
- # local — kept out of git via .gitignore by default.
114
- #
115
- # Storage mode: ${storageMode}
116
- # crosstalk-root (container /crosstalk-root) → ${crosstalkRootHost}
117
- # crosstalk-state (container /var/lib/crosstalk-state) → ${crosstalkStateHost}
118
- # transport (container /var/lib/crosstalk-transport) → \${PWD} (bind to this directory)
52
+ function renderCompose({ name, image, apiPort, alias, uid, gid, paths }) {
53
+ return `# Generated by 'crosstalk up'. Machine-local — runtime-owned.
54
+ # Storage mode: ${paths.mode}
55
+ # transport (container /var/lib/crosstalk-transport) → ${paths.transportDir}
56
+ # crosstalk-root (container /crosstalk-root) ${paths.crosstalkRoot}
57
+ # crosstalk-state (container /var/lib/crosstalk-state) → ${paths.crosstalkState}
119
58
 
120
59
  services:
121
60
  crosstalkd:
122
61
  image: ${image}
123
- container_name: crosstalk-${name}
62
+ container_name: ${name}
124
63
  restart: unless-stopped
64
+ labels:
65
+ ${CROSSTALK_LABEL.replace('=', ': "')}"
125
66
  ports:
126
67
  - "127.0.0.1:${apiPort}:7000"
127
68
  volumes:
128
- # Transport bind-mount: \`git remote add\` from the host is visible
129
- # to the engine on the next tick. No clone-on-startup needed.
130
- - .:/var/lib/crosstalk-transport
131
- # Operator-installed agent CLIs (claude, codex, agy, ...) + auth
132
- # state. Chowned to operator UID at entrypoint so host-side
133
- # \`rm -rf\` works without sudo.
134
- - ${crosstalkRootHost}:/crosstalk-root
135
- # Dispatcher's machine-local state: cursor, heartbeat, errors.log,
136
- # pidfile, wake.signal. Operator-UID-owned (chowned by entrypoint).
137
- - ${crosstalkStateHost}:/var/lib/crosstalk-state
69
+ - ${paths.transportDir}:/var/lib/crosstalk-transport
70
+ - ${paths.crosstalkRoot}:/crosstalk-root
71
+ - ${paths.crosstalkState}:/var/lib/crosstalk-state
72
+ env_file:
73
+ - ${paths.authEnvFile}
138
74
  environment:
139
75
  CROSSTALK_ALIAS: ${alias}
140
76
  CROSSTALK_UID: "${uid}"
@@ -145,73 +81,49 @@ services:
145
81
  `;
146
82
  }
147
83
 
148
- function ensureGitignored(transportRoot) {
149
- const path = join(transportRoot, '.gitignore');
150
- const entry = 'docker-compose.yml\n';
151
- if (!existsSync(path)) {
152
- writeFileSync(path, entry);
153
- return;
154
- }
155
- const current = readFileSync(path, 'utf-8');
156
- if (!current.split('\n').includes('docker-compose.yml')) {
157
- appendFileSync(path, (current.endsWith('\n') ? '' : '\n') + entry);
158
- }
159
- }
160
-
161
84
  export async function run(argv) {
162
85
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
163
86
 
164
- const root = requireTransportRoot();
165
- const composeYml = composeFile(root);
166
- const generated = !existsSync(composeYml);
87
+ const { name, paths } = requireInitialized(argv);
88
+
89
+ // Case 1: already running → error, don't surprise the operator with
90
+ // a silent no-op or implicit restart.
91
+ if (isRunning(name)) {
92
+ process.stderr.write(
93
+ `crosstalk up: '${name}' is already running.\n` +
94
+ ` Use 'crosstalk chat${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to attach,\n` +
95
+ ` 'crosstalk restart${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to recreate,\n` +
96
+ ` or 'crosstalk down${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' first.\n`,
97
+ );
98
+ return 1;
99
+ }
100
+
101
+ // Case "stopped but not removed" — `docker stop` without compose down.
102
+ // Silently remove the dead container so the recreate works.
103
+ if (containerExists(name)) {
104
+ spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
105
+ }
167
106
 
107
+ // Compose file generation. If operator edited theirs, respect it.
108
+ const generated = !existsSync(paths.composeFile);
168
109
  if (generated) {
169
- const alias = process.env.CROSSTALK_ALIAS ?? process.env.HOSTNAME ?? transportName(root);
110
+ const alias = process.env.CROSSTALK_ALIAS ?? name;
170
111
  const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
171
112
  const gid = typeof process.getgid === 'function' ? process.getgid() : 1000;
172
- let storage;
173
- try {
174
- storage = resolveStoragePaths(transportName(root), STORAGE_MODE);
175
- } catch (err) {
176
- process.stderr.write(`${err.message}\n`);
177
- return 1;
178
- }
179
- // Pre-create the host bind-mount directories. Docker would auto-
180
- // create them as root-owned otherwise, which would break the
181
- // operator-UID chown step inside the container.
182
- try {
183
- mkdirSync(storage.crosstalkRoot, { recursive: true });
184
- mkdirSync(storage.crosstalkState, { recursive: true });
185
- } catch (err) {
186
- process.stderr.write(
187
- `crosstalk up: failed to create storage directories — ${err.message}\n`,
188
- );
189
- if (storage.mode === 'system') {
190
- process.stderr.write(
191
- ` System-mode storage requires write access to ${storage.crosstalkRoot}'s parent.\n` +
192
- ` Try: sudo mkdir -p ${storage.crosstalkRoot} ${storage.crosstalkState} && sudo chown -R \$USER ${storage.crosstalkRoot} ${storage.crosstalkState}\n`,
193
- );
194
- }
195
- return 1;
196
- }
197
- const content = renderCompose({
198
- name: transportName(root),
199
- image: DEFAULT_IMAGE,
200
- apiPort: DEFAULT_API_PORT,
201
- alias,
202
- uid,
203
- gid,
204
- crosstalkRootHost: storage.crosstalkRoot,
205
- crosstalkStateHost: storage.crosstalkState,
206
- storageMode: storage.mode,
207
- });
208
- writeFileSync(composeYml, content);
209
- ensureGitignored(root);
210
- process.stdout.write(`Generated ${composeYml} (storage: ${storage.mode})\n`);
113
+ const apiPort = apiPortFor(name);
114
+ const image = DEFAULT_IMAGE;
115
+ const content = renderCompose({ name, image, apiPort, alias, uid, gid, paths });
116
+ writeFileSync(paths.composeFile, content);
117
+ }
118
+
119
+ // Resume notice when storage is non-fresh (operator brought it up
120
+ // before, has installed CLIs / auth state in crosstalk-root/). Heuristic:
121
+ // crosstalk-root has more than just a .gitkeep / nothing.
122
+ if (!generated) {
123
+ process.stdout.write(`Resuming '${name}' (existing state at ${paths.storageRoot})\n`);
211
124
  }
212
125
 
213
- const r = spawnSync('docker', ['compose', '-f', composeYml, 'up', '-d'], {
214
- cwd: root,
126
+ const r = spawnSync('docker', ['compose', '-f', paths.composeFile, 'up', '-d'], {
215
127
  stdio: 'inherit',
216
128
  });
217
129
  if (r.status !== 0) {
@@ -219,7 +131,7 @@ export async function run(argv) {
219
131
  return r.status ?? 1;
220
132
  }
221
133
  process.stdout.write(
222
- `\nEngine starting. Check status with 'crosstalk status' (give it ~5s to settle).\n`,
134
+ `\n'${name}' starting. Check with 'crosstalk status${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' (give it ~5s).\n`,
223
135
  );
224
136
  return 0;
225
137
  }
@@ -7,14 +7,15 @@
7
7
  import { readFileSync } from 'fs';
8
8
  import { dirname, join } from 'path';
9
9
  import { fileURLToPath } from 'url';
10
- import { api, ConnectError } from '../lib/api-client.js';
10
+ import { apiFor, ConnectError } from '../lib/api-client.js';
11
11
 
12
12
  const thisDir = dirname(fileURLToPath(import.meta.url));
13
13
  const pkgPath = join(thisDir, '..', 'package.json');
14
14
  const clientPkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
15
15
 
16
- export async function run(_argv) {
16
+ export async function run(argv) {
17
17
  process.stdout.write(`crosstalk client: ${clientPkg.version}\n`);
18
+ const api = apiFor(argv);
18
19
  try {
19
20
  // Skew detection happens passively in api-client.js via the
20
21
  // X-Crosstalk-Engine-Version response header — no duplication here.
package/lib/api-client.js CHANGED
@@ -1,26 +1,27 @@
1
1
  // api-client.js — HTTP client over loopback to crosstalkd's local API.
2
2
  //
3
3
  // All operator commands route through this. The engine binds 127.0.0.1
4
- // inside the container; docker-compose maps that to the host loopback
5
- // at the same port. Client connects to http://127.0.0.1:<port>/<path>.
4
+ // inside the container; docker-compose maps that to the host loopback.
5
+ // The host port differs per container — default container 'crosstalk'
6
+ // gets 7000; named containers pick a free port at init time, recorded in
7
+ // `<base>/<name>/api-port`. apiPortFor() resolves name → port.
6
8
  //
7
- // Port resolution (in order):
8
- // 1. --port <n> CLI flag (handled by individual commands)
9
- // 2. CROSSTALK_API_PORT env var
10
- // 3. Default 7000
9
+ // Port resolution order:
10
+ // 1. opts.port (explicit override from caller)
11
+ // 2. CROSSTALK_API_PORT env var (test/dev override)
12
+ // 3. apiPortFor(name) — reads `<base>/<name>/api-port` file
13
+ // 4. DEFAULT_API_PORT (7000) as last resort
11
14
  //
12
- // No auth: the engine binds 127.0.0.1 only and there's no token. Same
13
- // model as ollama / postgres-on-localhost.
15
+ // No auth: engine binds 127.0.0.1 only, no token. Same model as ollama.
14
16
  //
15
- // Version skew: engine sets X-Crosstalk-Engine-Version on every
16
- // response. We compare it to the client's package.json version on each
17
- // call and emit one warning per process if they don't match. Pre-1.0
18
- // client and engine ship in lockstep; mismatches usually mean the
19
- // operator upgraded one without the other.
17
+ // Version skew: engine sets X-Crosstalk-Engine-Version on every response.
18
+ // We compare to client version on each call, emit one warning per process
19
+ // if mismatched. Pre-1.0 client and engine ship in lockstep.
20
20
 
21
21
  import { readFileSync } from 'fs';
22
22
  import { dirname, join } from 'path';
23
23
  import { fileURLToPath } from 'url';
24
+ import { apiPortFor, DEFAULT_API_PORT, parseContainerName } from './resolve.js';
24
25
 
25
26
  const CLIENT_VERSION = (() => {
26
27
  try {
@@ -44,12 +45,13 @@ function checkSkew(engineVersion) {
44
45
  );
45
46
  }
46
47
 
47
- export const DEFAULT_API_PORT = 7000;
48
+ export { DEFAULT_API_PORT };
48
49
 
49
- export function resolvePort(explicit) {
50
- if (typeof explicit === 'number' && Number.isInteger(explicit)) return explicit;
50
+ export function resolvePort(opts = {}) {
51
+ if (typeof opts.port === 'number' && Number.isInteger(opts.port)) return opts.port;
51
52
  const fromEnv = Number(process.env.CROSSTALK_API_PORT);
52
53
  if (Number.isInteger(fromEnv) && fromEnv > 0) return fromEnv;
54
+ if (opts.name) return apiPortFor(opts.name);
53
55
  return DEFAULT_API_PORT;
54
56
  }
55
57
 
@@ -91,7 +93,7 @@ export class ConnectError extends Error {
91
93
  }
92
94
 
93
95
  async function call(method, path, body, opts = {}) {
94
- const port = resolvePort(opts.port);
96
+ const port = resolvePort(opts);
95
97
  const init = {
96
98
  method,
97
99
  headers: body ? { 'Content-Type': 'application/json' } : undefined,
@@ -107,6 +109,30 @@ async function call(method, path, body, opts = {}) {
107
109
  return parseJsonOrThrow(res);
108
110
  }
109
111
 
112
+ // Bind an api client to a specific container name. All callers should
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. Validation
115
+ // errors print cleanly (no stack trace) — bug C fix.
116
+ export function apiFor(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
+ }
124
+ return {
125
+ name,
126
+ get: (path, opts = {}) => call('GET', path, undefined, { ...opts, name }),
127
+ post: (path, body, opts = {}) => call('POST', path, body, { ...opts, name }),
128
+ patch: (path, body, opts = {}) => call('PATCH', path, body, { ...opts, name }),
129
+ delete: (path, opts = {}) => call('DELETE', path, undefined, { ...opts, name }),
130
+ };
131
+ }
132
+
133
+ // Legacy entry point — env-driven port (CROSSTALK_API_PORT or 7000),
134
+ // no name resolution. Kept for harness compatibility; new code should
135
+ // use apiFor(argv) instead.
110
136
  export const api = {
111
137
  get: (path, opts) => call('GET', path, undefined, opts),
112
138
  post: (path, body, opts) => call('POST', path, body, opts),
package/lib/argv.js CHANGED
@@ -12,13 +12,15 @@ export function has(argv, name) {
12
12
  return argv.includes(name);
13
13
  }
14
14
 
15
- // All positional (non-flag) args, skipping flag values.
15
+ // All positional (non-flag) args, skipping flag values for the listed
16
+ // flags. Handles both --long and -short forms; flag names in
17
+ // flagsWithValue must match the form they appear in argv.
16
18
  export function positionals(argv, flagsWithValue) {
17
19
  const valueFlags = new Set(flagsWithValue);
18
20
  const out = [];
19
21
  for (let i = 0; i < argv.length; i++) {
20
22
  const a = argv[i];
21
- if (a.startsWith('--')) {
23
+ if (a.startsWith('-')) {
22
24
  if (valueFlags.has(a)) i++;
23
25
  continue;
24
26
  }
package/lib/resolve.js ADDED
@@ -0,0 +1,192 @@
1
+ // resolve.js — name-based resolution for the runtime-owned-transport
2
+ // model (alpha.6+).
3
+ //
4
+ // Operator addresses transports by container name, picked at `crosstalk
5
+ // init`. Default container is literally `crosstalk`; named containers
6
+ // are literally whatever the operator typed into --containername.
7
+ // Identity lives with the container; storage lives at <base>/<name>/.
8
+ //
9
+ // Replaces the cwd-walking transport.js from alpha.5 (which derived
10
+ // identity from the operator's filesystem position). The new model is
11
+ // position-independent — operator can be anywhere on disk.
12
+
13
+ import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs';
14
+ import { spawnSync } from 'child_process';
15
+ import { join } from 'path';
16
+ import { homedir } from 'os';
17
+
18
+ export const DEFAULT_CONTAINER_NAME = 'crosstalk';
19
+ export const DEFAULT_API_PORT = 7000;
20
+ export const CROSSTALK_LABEL = 'crosstalk.transport=true';
21
+
22
+ // Per-OS storage bases. User-mode (default) keeps everything under the
23
+ // operator's home — no sudo/UAC, no Docker Desktop file-sharing allowlist
24
+ // gymnastics. System-mode is for headless multi-operator deployments.
25
+ export function resolveBase(mode) {
26
+ const platform = process.platform;
27
+ const bases = (platform === 'darwin')
28
+ ? {
29
+ user: join(homedir(), 'Library', 'Application Support', 'crosstalk'),
30
+ system: '/Library/Application Support/crosstalk',
31
+ }
32
+ : (platform === 'win32')
33
+ ? {
34
+ user: process.env['LOCALAPPDATA']
35
+ ? join(process.env['LOCALAPPDATA'], 'crosstalk')
36
+ : join(homedir(), 'AppData', 'Local', 'crosstalk'),
37
+ system: 'C:\\ProgramData\\crosstalk',
38
+ }
39
+ : {
40
+ user: join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'crosstalk'),
41
+ system: '/var/lib/crosstalk',
42
+ };
43
+
44
+ if (mode !== 'user' && mode !== 'system') {
45
+ throw new Error(`CROSSTALK_STORAGE_MODE='${mode}' invalid — must be 'user' or 'system'.`);
46
+ }
47
+ return bases[mode];
48
+ }
49
+
50
+ export function storageMode() {
51
+ return (process.env.CROSSTALK_STORAGE_MODE ?? 'user').toLowerCase();
52
+ }
53
+
54
+ // All storage paths for a given container name, derived from the resolved
55
+ // base. None of these are validated for existence here — callers check
56
+ // based on context (init pre-creates, up verifies, etc).
57
+ export function storagePaths(name, mode = storageMode()) {
58
+ const base = resolveBase(mode);
59
+ const root = join(base, name);
60
+ return {
61
+ base,
62
+ mode,
63
+ storageRoot: root,
64
+ transportDir: join(root, 'transport'),
65
+ crosstalkRoot: join(root, 'crosstalk-root'),
66
+ crosstalkState: join(root, 'crosstalk-state'),
67
+ composeFile: join(root, 'docker-compose.yml'),
68
+ portFile: join(root, 'api-port'),
69
+ authEnvFile: join(root, 'auth.env'),
70
+ };
71
+ }
72
+
73
+ // Container name from --containername flag, defaulting to `crosstalk`.
74
+ // Validates the name is a sane Docker container name + filesystem dir
75
+ // name: lowercase alphanumeric, `_.-`, no leading dot/hyphen.
76
+ const NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/;
77
+ export function parseContainerName(argv) {
78
+ let name = DEFAULT_CONTAINER_NAME;
79
+ for (let i = 0; i < argv.length; i++) {
80
+ if (argv[i] === '--containername' || argv[i] === '-c') {
81
+ const val = argv[i + 1];
82
+ if (!val) throw new Error('--containername requires a value');
83
+ name = val;
84
+ break;
85
+ }
86
+ }
87
+ if (!NAME_RE.test(name)) {
88
+ throw new Error(
89
+ `--containername '${name}' invalid — must be lowercase alphanumeric ` +
90
+ `with optional ._- (no leading . or -).`,
91
+ );
92
+ }
93
+ return name;
94
+ }
95
+
96
+ // Read the API port for a given container. Source-of-truth lookup:
97
+ // 1. `<base>/<name>/api-port` file (written at init time)
98
+ // 2. Fallback: DEFAULT_API_PORT
99
+ // We use a file rather than `docker inspect` because the resolver runs
100
+ // before the container exists (`init`) and after it's gone (`rm` cleanup).
101
+ // Docker is authoritative for what's running; the port file is
102
+ // authoritative for which port a given container WOULD use.
103
+ export function apiPortFor(name) {
104
+ const paths = storagePaths(name);
105
+ if (existsSync(paths.portFile)) {
106
+ const v = Number(readFileSync(paths.portFile, 'utf-8').trim());
107
+ if (Number.isInteger(v) && v > 0 && v < 65536) return v;
108
+ }
109
+ return DEFAULT_API_PORT;
110
+ }
111
+
112
+ // Pick a free TCP port. Default container always gets DEFAULT_API_PORT
113
+ // (7000) so operators have a predictable target. Named containers search
114
+ // upward from 7001 until they find one that doesn't collide with already-
115
+ // allocated containers (any `<base>/*/api-port` file).
116
+ export function pickFreePort(name, mode = storageMode()) {
117
+ if (name === DEFAULT_CONTAINER_NAME) return DEFAULT_API_PORT;
118
+ const base = resolveBase(mode);
119
+ const taken = new Set([DEFAULT_API_PORT]);
120
+ try {
121
+ const dirs = readdirSync(base);
122
+ for (const d of dirs) {
123
+ const portFile = join(base, d, 'api-port');
124
+ if (existsSync(portFile)) {
125
+ const v = Number(readFileSync(portFile, 'utf-8').trim());
126
+ if (Number.isInteger(v)) taken.add(v);
127
+ }
128
+ }
129
+ } catch { /* base may not exist yet */ }
130
+ for (let p = 7001; p < 8000; p++) {
131
+ if (!taken.has(p)) return p;
132
+ }
133
+ throw new Error('Could not find a free port between 7001 and 7999.');
134
+ }
135
+
136
+ // True if `<base>/<name>/transport/.git` exists. The cheapest signal that
137
+ // a transport has been initialized for this name.
138
+ export function isInitialized(name) {
139
+ const paths = storagePaths(name);
140
+ return existsSync(join(paths.transportDir, '.git'));
141
+ }
142
+
143
+ // True if a running container exists for this name. Uses Docker as the
144
+ // source of truth, filtered by our label to avoid confusing
145
+ // operator-named-`crosstalk` containers from other workloads.
146
+ export function isRunning(name) {
147
+ const r = spawnSync(
148
+ 'docker',
149
+ ['ps', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
150
+ { encoding: 'utf-8' },
151
+ );
152
+ if (r.status !== 0) return false;
153
+ return r.stdout.trim().split('\n').includes(name);
154
+ }
155
+
156
+ // True if a stopped container exists (less common path; usually `down`
157
+ // removes the container). Checked separately so `up` can decide whether
158
+ // to `docker rm` and recreate.
159
+ export function containerExists(name) {
160
+ const r = spawnSync(
161
+ 'docker',
162
+ ['ps', '-a', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
163
+ { encoding: 'utf-8' },
164
+ );
165
+ if (r.status !== 0) return false;
166
+ return r.stdout.trim().split('\n').includes(name);
167
+ }
168
+
169
+ // Look up the resolved name+paths from argv, validate that init has run,
170
+ // and exit with a clear error if not. For verbs that operate on existing
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.
176
+ export function requireInitialized(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
+ }
184
+ if (!isInitialized(name)) {
185
+ process.stderr.write(
186
+ `crosstalk: no transport '${name}' on this machine.\n` +
187
+ ` Run 'crosstalk init${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to set one up.\n`,
188
+ );
189
+ process.exit(2);
190
+ }
191
+ return { name, paths: storagePaths(name) };
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "7.0.0-alpha.5",
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",