@cordfuse/crosstalk 7.0.0-alpha.1 → 7.0.0-alpha.10

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.
@@ -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,14 +18,45 @@ 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
- process.stdout.write(' (no model CLIs found inside the container — install one or edit data/models.yaml)\n');
29
+ let installed = null;
30
+ let yamlReferenced = null;
31
+ try {
32
+ const r = await api.get('/agents/installed');
33
+ installed = r.installed;
34
+ yamlReferenced = r.yaml_referenced;
35
+ } catch { /* ignore — fall back to generic message */ }
36
+ if (installed && installed.length > 0) {
37
+ // M2 (alpha.10): differentiate "yaml references it but the claim
38
+ // list is stale" from "yaml has no entry yet". The dispatcher
39
+ // claims at startup; a CLI installed after `crosstalk up` doesn't
40
+ // surface until `crosstalk restart`.
41
+ const referencedSet = new Set(yamlReferenced ?? []);
42
+ const installedAndReferenced = installed.filter((b) => referencedSet.has(b));
43
+ const installedNotReferenced = installed.filter((b) => !referencedSet.has(b));
44
+ if (installedAndReferenced.length > 0) {
45
+ const verb = installedAndReferenced.length === 1 ? 'is' : 'are';
46
+ process.stdout.write(
47
+ ` (${installedAndReferenced.join(', ')} ${verb} installed and referenced in data/crosstalk.yaml ` +
48
+ `but not yet claimed — run 'crosstalk restart' to refresh the dispatcher's claim list)\n`,
49
+ );
50
+ }
51
+ if (installedNotReferenced.length > 0) {
52
+ const verb = installedNotReferenced.length === 1 ? 'references it' : 'references them';
53
+ process.stdout.write(
54
+ ` (${installedNotReferenced.join(', ')} installed in the container but no data/crosstalk.yaml entry ${verb} — add one to claim)\n`,
55
+ );
56
+ }
57
+ } else {
58
+ 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');
59
+ }
22
60
  } else {
23
61
  for (const m of s.claimed_models) process.stdout.write(` - ${m}\n`);
24
62
  }
@@ -41,9 +79,9 @@ export async function run(_argv) {
41
79
 
42
80
  process.stdout.write(`Channels: ${s.channels.length}\n`);
43
81
  for (const ch of s.channels) {
44
- const name = ch.name ?? '(unnamed)';
82
+ const chName = ch.name ?? '(unnamed)';
45
83
  const parentSuffix = ch.parent ? ` [child of ${ch.parent.slice(0, 8)}]` : '';
46
- process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${name}${parentSuffix}\n`);
84
+ process.stdout.write(` ${ch.uuid.slice(0, 8)}... — ${chName}${parentSuffix}\n`);
47
85
  }
48
86
  process.stdout.write('\n');
49
87
 
package/commands/up.js CHANGED
@@ -1,68 +1,74 @@
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, 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 { requireTransportRoot, transportName, composeFile } from '../lib/transport.js';
16
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';
17
25
 
18
26
  const DEFAULT_IMAGE = process.env.CROSSTALK_IMAGE
19
- ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.1';
20
- const DEFAULT_API_PORT = Number(process.env.CROSSTALK_API_PORT) || 7000;
27
+ ?? 'ghcr.io/cordfuse/crosstalk-server:7.0.0-alpha.8';
21
28
 
22
29
  function usage(exit = 0) {
23
30
  const w = exit === 0 ? process.stdout : process.stderr;
24
31
  w.write(
25
- `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.
26
37
 
27
- Brings up the engine container for the transport in the current directory
28
- (or any ancestor containing CROSSTALK-VERSION). First-time use generates
29
- docker-compose.yml in the transport root. Override the image with
30
- CROSSTALK_IMAGE, the API port with CROSSTALK_API_PORT.
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.
42
+
43
+ Environment:
44
+ CROSSTALK_IMAGE Override the engine image
45
+ CROSSTALK_ALIAS Override the engine's machine identity
46
+ (defaults to the container name)
31
47
  `,
32
48
  );
33
49
  process.exit(exit);
34
50
  }
35
51
 
36
- function renderCompose({ name, image, apiPort, alias, uid, gid }) {
37
- // Entrypoint runs as root for setup (apt-installed CLIs in /root,
38
- // npm prefix, SSH key import). It then adjusts a 'crosstalkd' user
39
- // to match CROSSTALK_UID/GID (operator's host UID), chowns the
40
- // bind-mounted transport + state dir to that user, and drops via
41
- // setpriv before exec'ing the dispatcher. Net effect: bind-mount
42
- // files written by the engine are operator-owned on the host, so
43
- // \`git remote add\` and friends work without sudo. /root stays
44
- // root-owned for CLI installs.
45
- return `# Generated by \`crosstalk up\`. Safe to edit; subsequent \`up\`s
46
- # only regenerate when this file is missing. Compose config is machine-
47
- # local — kept out of git via .gitignore by default.
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}
48
58
 
49
59
  services:
50
60
  crosstalkd:
51
61
  image: ${image}
52
- container_name: crosstalk-${name}
62
+ container_name: ${name}
53
63
  restart: unless-stopped
64
+ labels:
65
+ ${CROSSTALK_LABEL.replace('=', ': "')}"
54
66
  ports:
55
67
  - "127.0.0.1:${apiPort}:7000"
56
68
  volumes:
57
- # Transport bind-mount: \`git remote add\` from the host is visible
58
- # to the engine on the next tick. No clone-on-startup needed.
59
- - .:/var/lib/crosstalk-transport
60
- # Operator-installed agent CLIs (claude, codex, agy, ...) + auth
61
- # state. Root-owned, dispatcher reads only.
62
- - crosstalk-root:/root
63
- # Dispatcher's machine-local state: cursor, heartbeat, errors.log.
64
- # Operator-UID-owned (chowned by entrypoint to CROSSTALK_UID/GID).
65
- - crosstalk-state:/var/lib/crosstalkd-state
69
+ - ${paths.transportDir}:/var/lib/crosstalk-transport
70
+ - ${paths.crosstalkRoot}:/crosstalk-root
71
+ - ${paths.crosstalkState}:/var/lib/crosstalk-state
66
72
  environment:
67
73
  CROSSTALK_ALIAS: ${alias}
68
74
  CROSSTALK_UID: "${uid}"
@@ -70,52 +76,52 @@ services:
70
76
  CROSSTALKD_API_PORT: "7000"
71
77
  DISPATCH_JSON: "true"
72
78
  DISPATCH_POLL_SECONDS: "30"
73
-
74
- volumes:
75
- crosstalk-root:
76
- crosstalk-state:
77
79
  `;
78
80
  }
79
81
 
80
- function ensureGitignored(transportRoot) {
81
- const path = join(transportRoot, '.gitignore');
82
- const entry = 'docker-compose.yml\n';
83
- if (!existsSync(path)) {
84
- writeFileSync(path, entry);
85
- return;
86
- }
87
- const current = readFileSync(path, 'utf-8');
88
- if (!current.split('\n').includes('docker-compose.yml')) {
89
- appendFileSync(path, (current.endsWith('\n') ? '' : '\n') + entry);
90
- }
91
- }
92
-
93
82
  export async function run(argv) {
94
83
  if (has(argv, '--help') || has(argv, '-h')) usage(0);
95
84
 
96
- const root = requireTransportRoot();
97
- const composeYml = composeFile(root);
98
- const generated = !existsSync(composeYml);
85
+ const { name, paths } = requireInitialized(argv);
86
+
87
+ // Case 1: already running → error, don't surprise the operator with
88
+ // a silent no-op or implicit restart.
89
+ if (isRunning(name)) {
90
+ process.stderr.write(
91
+ `crosstalk up: '${name}' is already running.\n` +
92
+ ` Use 'crosstalk chat${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to attach,\n` +
93
+ ` 'crosstalk restart${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to recreate,\n` +
94
+ ` or 'crosstalk down${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' first.\n`,
95
+ );
96
+ return 1;
97
+ }
99
98
 
99
+ // Case "stopped but not removed" — `docker stop` without compose down.
100
+ // Silently remove the dead container so the recreate works.
101
+ if (containerExists(name)) {
102
+ spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
103
+ }
104
+
105
+ // Compose file generation. If operator edited theirs, respect it.
106
+ const generated = !existsSync(paths.composeFile);
100
107
  if (generated) {
101
- const alias = process.env.CROSSTALK_ALIAS ?? process.env.HOSTNAME ?? transportName(root);
108
+ const alias = process.env.CROSSTALK_ALIAS ?? name;
102
109
  const uid = typeof process.getuid === 'function' ? process.getuid() : 1000;
103
110
  const gid = typeof process.getgid === 'function' ? process.getgid() : 1000;
104
- const content = renderCompose({
105
- name: transportName(root),
106
- image: DEFAULT_IMAGE,
107
- apiPort: DEFAULT_API_PORT,
108
- alias,
109
- uid,
110
- gid,
111
- });
112
- writeFileSync(composeYml, content);
113
- ensureGitignored(root);
114
- process.stdout.write(`Generated ${composeYml}\n`);
111
+ const apiPort = apiPortFor(name);
112
+ const image = DEFAULT_IMAGE;
113
+ const content = renderCompose({ name, image, apiPort, alias, uid, gid, paths });
114
+ writeFileSync(paths.composeFile, content);
115
+ }
116
+
117
+ // Resume notice when storage is non-fresh (operator brought it up
118
+ // before, has installed CLIs / auth state in crosstalk-root/). Heuristic:
119
+ // crosstalk-root has more than just a .gitkeep / nothing.
120
+ if (!generated) {
121
+ process.stdout.write(`Resuming '${name}' (existing state at ${paths.storageRoot})\n`);
115
122
  }
116
123
 
117
- const r = spawnSync('docker', ['compose', '-f', composeYml, 'up', '-d'], {
118
- cwd: root,
124
+ const r = spawnSync('docker', ['compose', '-f', paths.composeFile, 'up', '-d'], {
119
125
  stdio: 'inherit',
120
126
  });
121
127
  if (r.status !== 0) {
@@ -123,7 +129,7 @@ export async function run(argv) {
123
129
  return r.status ?? 1;
124
130
  }
125
131
  process.stdout.write(
126
- `\nEngine starting. Check status with 'crosstalk status' (give it ~5s to settle).\n`,
132
+ `\n'${name}' starting. Check with 'crosstalk status${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' (give it ~5s).\n`,
127
133
  );
128
134
  return 0;
129
135
  }
@@ -7,15 +7,18 @@
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 {
20
+ // Skew detection happens passively in api-client.js via the
21
+ // X-Crosstalk-Engine-Version response header — no duplication here.
19
22
  const v = await api.get('/version');
20
23
  process.stdout.write(`crosstalkd engine: ${v.version} (alias: ${v.alias})\n`);
21
24
  } catch (err) {
package/lib/api-client.js CHANGED
@@ -1,23 +1,57 @@
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.
16
+ //
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
+
21
+ import { readFileSync } from 'fs';
22
+ import { dirname, join } from 'path';
23
+ import { fileURLToPath } from 'url';
24
+ import { apiPortFor, DEFAULT_API_PORT, parseContainerName } from './resolve.js';
25
+
26
+ const CLIENT_VERSION = (() => {
27
+ try {
28
+ const thisDir = dirname(fileURLToPath(import.meta.url));
29
+ return JSON.parse(readFileSync(join(thisDir, '..', 'package.json'), 'utf-8')).version;
30
+ } catch {
31
+ return 'unknown';
32
+ }
33
+ })();
14
34
 
15
- export const DEFAULT_API_PORT = 7000;
35
+ let skewWarned = false;
36
+
37
+ function checkSkew(engineVersion) {
38
+ if (skewWarned || !engineVersion || engineVersion === CLIENT_VERSION) return;
39
+ skewWarned = true;
40
+ process.stderr.write(
41
+ `[WARN] Version skew: client ${CLIENT_VERSION}, engine ${engineVersion}.\n` +
42
+ ` Pre-1.0 client and engine ship in lockstep. Likely fix:\n` +
43
+ ` crosstalk pull && crosstalk restart # if engine is behind\n` +
44
+ ` npm install -g @cordfuse/crosstalk # if client is behind\n\n`,
45
+ );
46
+ }
16
47
 
17
- export function resolvePort(explicit) {
18
- if (typeof explicit === 'number' && Number.isInteger(explicit)) return explicit;
48
+ export { DEFAULT_API_PORT };
49
+
50
+ export function resolvePort(opts = {}) {
51
+ if (typeof opts.port === 'number' && Number.isInteger(opts.port)) return opts.port;
19
52
  const fromEnv = Number(process.env.CROSSTALK_API_PORT);
20
53
  if (Number.isInteger(fromEnv) && fromEnv > 0) return fromEnv;
54
+ if (opts.name) return apiPortFor(opts.name);
21
55
  return DEFAULT_API_PORT;
22
56
  }
23
57
 
@@ -59,7 +93,7 @@ export class ConnectError extends Error {
59
93
  }
60
94
 
61
95
  async function call(method, path, body, opts = {}) {
62
- const port = resolvePort(opts.port);
96
+ const port = resolvePort(opts);
63
97
  const init = {
64
98
  method,
65
99
  headers: body ? { 'Content-Type': 'application/json' } : undefined,
@@ -71,10 +105,37 @@ async function call(method, path, body, opts = {}) {
71
105
  } catch (err) {
72
106
  throw new ConnectError(port, err.message || String(err));
73
107
  }
108
+ checkSkew(res.headers.get('x-crosstalk-engine-version'));
74
109
  return parseJsonOrThrow(res);
75
110
  }
76
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.
77
136
  export const api = {
78
- get: (path, opts) => call('GET', path, undefined, opts),
79
- post: (path, body, opts) => call('POST', path, body, opts),
137
+ get: (path, opts) => call('GET', path, undefined, opts),
138
+ post: (path, body, opts) => call('POST', path, body, opts),
139
+ patch: (path, body, opts) => call('PATCH', path, body, opts),
140
+ delete: (path, opts) => call('DELETE', path, undefined, opts),
80
141
  };
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,191 @@
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
+ };
70
+ }
71
+
72
+ // Container name from --containername flag, defaulting to `crosstalk`.
73
+ // Validates the name is a sane Docker container name + filesystem dir
74
+ // name: lowercase alphanumeric, `_.-`, no leading dot/hyphen.
75
+ const NAME_RE = /^[a-z0-9][a-z0-9_.-]*$/;
76
+ export function parseContainerName(argv) {
77
+ let name = DEFAULT_CONTAINER_NAME;
78
+ for (let i = 0; i < argv.length; i++) {
79
+ if (argv[i] === '--containername' || argv[i] === '-c') {
80
+ const val = argv[i + 1];
81
+ if (!val) throw new Error('--containername requires a value');
82
+ name = val;
83
+ break;
84
+ }
85
+ }
86
+ if (!NAME_RE.test(name)) {
87
+ throw new Error(
88
+ `--containername '${name}' invalid — must be lowercase alphanumeric ` +
89
+ `with optional ._- (no leading . or -).`,
90
+ );
91
+ }
92
+ return name;
93
+ }
94
+
95
+ // Read the API port for a given container. Source-of-truth lookup:
96
+ // 1. `<base>/<name>/api-port` file (written at init time)
97
+ // 2. Fallback: DEFAULT_API_PORT
98
+ // We use a file rather than `docker inspect` because the resolver runs
99
+ // before the container exists (`init`) and after it's gone (`rm` cleanup).
100
+ // Docker is authoritative for what's running; the port file is
101
+ // authoritative for which port a given container WOULD use.
102
+ export function apiPortFor(name) {
103
+ const paths = storagePaths(name);
104
+ if (existsSync(paths.portFile)) {
105
+ const v = Number(readFileSync(paths.portFile, 'utf-8').trim());
106
+ if (Number.isInteger(v) && v > 0 && v < 65536) return v;
107
+ }
108
+ return DEFAULT_API_PORT;
109
+ }
110
+
111
+ // Pick a free TCP port. Default container always gets DEFAULT_API_PORT
112
+ // (7000) so operators have a predictable target. Named containers search
113
+ // upward from 7001 until they find one that doesn't collide with already-
114
+ // allocated containers (any `<base>/*/api-port` file).
115
+ export function pickFreePort(name, mode = storageMode()) {
116
+ if (name === DEFAULT_CONTAINER_NAME) return DEFAULT_API_PORT;
117
+ const base = resolveBase(mode);
118
+ const taken = new Set([DEFAULT_API_PORT]);
119
+ try {
120
+ const dirs = readdirSync(base);
121
+ for (const d of dirs) {
122
+ const portFile = join(base, d, 'api-port');
123
+ if (existsSync(portFile)) {
124
+ const v = Number(readFileSync(portFile, 'utf-8').trim());
125
+ if (Number.isInteger(v)) taken.add(v);
126
+ }
127
+ }
128
+ } catch { /* base may not exist yet */ }
129
+ for (let p = 7001; p < 8000; p++) {
130
+ if (!taken.has(p)) return p;
131
+ }
132
+ throw new Error('Could not find a free port between 7001 and 7999.');
133
+ }
134
+
135
+ // True if `<base>/<name>/transport/.git` exists. The cheapest signal that
136
+ // a transport has been initialized for this name.
137
+ export function isInitialized(name) {
138
+ const paths = storagePaths(name);
139
+ return existsSync(join(paths.transportDir, '.git'));
140
+ }
141
+
142
+ // True if a running container exists for this name. Uses Docker as the
143
+ // source of truth, filtered by our label to avoid confusing
144
+ // operator-named-`crosstalk` containers from other workloads.
145
+ export function isRunning(name) {
146
+ const r = spawnSync(
147
+ 'docker',
148
+ ['ps', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
149
+ { encoding: 'utf-8' },
150
+ );
151
+ if (r.status !== 0) return false;
152
+ return r.stdout.trim().split('\n').includes(name);
153
+ }
154
+
155
+ // True if a stopped container exists (less common path; usually `down`
156
+ // removes the container). Checked separately so `up` can decide whether
157
+ // to `docker rm` and recreate.
158
+ export function containerExists(name) {
159
+ const r = spawnSync(
160
+ 'docker',
161
+ ['ps', '-a', '--filter', `label=${CROSSTALK_LABEL}`, '--filter', `name=^${name}$`, '--format', '{{.Names}}'],
162
+ { encoding: 'utf-8' },
163
+ );
164
+ if (r.status !== 0) return false;
165
+ return r.stdout.trim().split('\n').includes(name);
166
+ }
167
+
168
+ // Look up the resolved name+paths from argv, validate that init has run,
169
+ // and exit with a clear error if not. For verbs that operate on existing
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.
175
+ export function requireInitialized(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
+ }
183
+ if (!isInitialized(name)) {
184
+ process.stderr.write(
185
+ `crosstalk: no transport '${name}' on this machine.\n` +
186
+ ` Run 'crosstalk init${name === DEFAULT_CONTAINER_NAME ? '' : ` --containername ${name}`}' to set one up.\n`,
187
+ );
188
+ process.exit(2);
189
+ }
190
+ return { name, paths: storagePaths(name) };
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cordfuse/crosstalk",
3
- "version": "7.0.0-alpha.1",
3
+ "version": "7.0.0-alpha.10",
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",