@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.1

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.
Files changed (72) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +118 -0
  3. package/LICENSE +21 -0
  4. package/README.md +402 -61
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +120 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +153 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/status.js +37 -13
  16. package/commands/token.js +136 -0
  17. package/commands/transport.js +270 -0
  18. package/commands/version.js +3 -3
  19. package/commands/workflow.js +234 -0
  20. package/deploy/crosstalk@.service +62 -0
  21. package/deploy/install.sh +82 -0
  22. package/lib/api-client.js +77 -22
  23. package/lib/credentials.js +207 -0
  24. package/lib/nativeServer.js +173 -0
  25. package/lib/resolve.js +101 -34
  26. package/package.json +27 -4
  27. package/src/activation.ts +104 -0
  28. package/src/api.ts +1716 -0
  29. package/src/auth/enforce.ts +68 -0
  30. package/src/auth/handlers.ts +266 -0
  31. package/src/auth/middleware.ts +132 -0
  32. package/src/auth/setup.ts +263 -0
  33. package/src/auth/tokens.ts +285 -0
  34. package/src/auth/users.ts +267 -0
  35. package/src/dispatch.ts +492 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +283 -0
  43. package/src/resolve.ts +100 -0
  44. package/src/state.ts +190 -0
  45. package/src/stop.ts +37 -0
  46. package/src/transport.ts +243 -0
  47. package/src/web/auth-pages.ts +160 -0
  48. package/src/web/channels.ts +395 -0
  49. package/src/web/chat-page.ts +636 -0
  50. package/src/web/chat-pty.ts +254 -0
  51. package/src/web/dashboard.ts +129 -0
  52. package/src/web/layout.ts +237 -0
  53. package/src/web/stubs.ts +510 -0
  54. package/src/web/workflows.ts +490 -0
  55. package/src/workflow.ts +470 -0
  56. package/template/CLAUDE.md +10 -0
  57. package/template/CROSSTALK-VERSION +1 -0
  58. package/template/CROSSTALK.md +262 -0
  59. package/template/PROTOCOL.md +70 -0
  60. package/template/README.md +64 -0
  61. package/template/auth/.gitkeep +0 -0
  62. package/template/auth/README.md +224 -0
  63. package/template/data/crosstalk.yaml +196 -0
  64. package/template/gitignore +4 -0
  65. package/commands/down.js +0 -40
  66. package/commands/init.js +0 -243
  67. package/commands/pull.js +0 -22
  68. package/commands/replies.js +0 -40
  69. package/commands/restart.js +0 -29
  70. package/commands/rm.js +0 -109
  71. package/commands/run.js +0 -115
  72. package/commands/up.js +0 -135
@@ -0,0 +1,234 @@
1
+ // crosstalk workflow <compose|run|status> — multi-step workflow ops.
2
+ //
3
+ // compose <prompt> Interactive: engine compiles the prompt into a
4
+ // workflow plan, prints the YAML for review, then
5
+ // submits it. Operator can edit before submit.
6
+ // run <file> Submit an already-authored workflow YAML file
7
+ // (replaces v7's `crosstalk run --type workflow`).
8
+ // status [--id <id>] Snapshot in-flight workflows (parent channel,
9
+ // phase, fanout state, completion). Without --id
10
+ // lists every workflow on the transport.
11
+
12
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, unlinkSync } from 'fs';
13
+ import { spawnSync } from 'child_process';
14
+ import { tmpdir } from 'os';
15
+ import { join } from 'path';
16
+ import { apiFor } from '../lib/api-client.js';
17
+ import { reportAndExit } from '../lib/errors.js';
18
+ import { flag, has, positionals } from '../lib/argv.js';
19
+
20
+ function usage(exit = 0) {
21
+ const w = exit === 0 ? process.stdout : process.stderr;
22
+ w.write(
23
+ `Usage:
24
+ crosstalk workflow compose <prompt> [--channel <name|uuid>] [--compiler <model>]
25
+ crosstalk workflow run <file> [--channel <name|uuid>] [--from <name>]
26
+ crosstalk workflow status [--id <childUuid>]
27
+ `,
28
+ );
29
+ process.exit(exit);
30
+ }
31
+
32
+ export async function run(argv) {
33
+ if (has(argv, '--help') || has(argv, '-h')) usage(0);
34
+ const sub = argv[0];
35
+ if (!sub) usage(1);
36
+ const rest = argv.slice(1);
37
+
38
+ if (sub === 'compose') return await runCompose(rest);
39
+ if (sub === 'run') return await runRun(rest);
40
+ if (sub === 'status') return await runStatus(rest);
41
+
42
+ process.stderr.write(`crosstalk workflow: unknown subcommand '${sub}'.\n`);
43
+ usage(1);
44
+ }
45
+
46
+ function promptLine(label) {
47
+ return new Promise((resolve) => {
48
+ process.stdout.write(label);
49
+ const stdin = process.stdin;
50
+ stdin.setEncoding('utf-8');
51
+ stdin.resume();
52
+ let buf = '';
53
+ const onData = (chunk) => {
54
+ buf += chunk;
55
+ const nl = buf.indexOf('\n');
56
+ if (nl !== -1) {
57
+ stdin.removeListener('data', onData);
58
+ stdin.pause();
59
+ resolve(buf.slice(0, nl).replace(/\r$/, ''));
60
+ }
61
+ };
62
+ stdin.on('data', onData);
63
+ });
64
+ }
65
+
66
+ function editInEditor(initial) {
67
+ // Resolve editor: $VISUAL → $EDITOR → vi (POSIX default). Returns the
68
+ // edited contents, or null if the operator quit without saving.
69
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
70
+ const tmp = join(mkdtempSync(join(tmpdir(), 'crosstalk-wf-')), 'workflow.yaml');
71
+ writeFileSync(tmp, initial);
72
+ const r = spawnSync(editor, [tmp], { stdio: 'inherit' });
73
+ if (r.status !== 0) {
74
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
75
+ return null;
76
+ }
77
+ let edited;
78
+ try { edited = readFileSync(tmp, 'utf-8'); } catch { edited = null; }
79
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
80
+ return edited;
81
+ }
82
+
83
+ async function runCompose(argv) {
84
+ const pos = positionals(argv, [
85
+ '--channel', '--compiler', '--containername', '-c',
86
+ '--profile', '--server',
87
+ ]);
88
+ const prompt = pos.join(' ').trim();
89
+ if (!prompt) {
90
+ process.stderr.write('crosstalk workflow compose: <prompt> required.\n');
91
+ return 1;
92
+ }
93
+
94
+ const api = apiFor(argv);
95
+ const compilerAgent = flag(argv, '--compiler');
96
+ const channel = flag(argv, '--channel');
97
+
98
+ process.stderr.write('compiling workflow plan...\n');
99
+ let composed;
100
+ try {
101
+ composed = await api.post('/api/workflows/compose', {
102
+ prompt,
103
+ ...(compilerAgent ? { compilerAgent } : {}),
104
+ });
105
+ } catch (err) {
106
+ reportAndExit(err, 'crosstalk workflow compose');
107
+ }
108
+
109
+ let yaml = composed.yaml;
110
+ process.stdout.write(`\n--- compiled plan (via ${composed.compiler}) ---\n${yaml}\n--- end plan ---\n\n`);
111
+
112
+ while (true) {
113
+ const choice = (await promptLine('Submit? [y]es / [e]dit / [n]o: ')).trim().toLowerCase() || 'n';
114
+ if (choice === 'n' || choice === 'no') {
115
+ process.stdout.write('aborted.\n');
116
+ return 0;
117
+ }
118
+ if (choice === 'e' || choice === 'edit') {
119
+ const edited = editInEditor(yaml);
120
+ if (edited == null) {
121
+ process.stderr.write('editor exited non-zero; keeping original yaml.\n');
122
+ continue;
123
+ }
124
+ yaml = edited;
125
+ process.stdout.write(`\n--- edited plan ---\n${yaml}\n--- end plan ---\n\n`);
126
+ continue;
127
+ }
128
+ if (choice === 'y' || choice === 'yes') break;
129
+ }
130
+
131
+ let submitted;
132
+ try {
133
+ submitted = await api.post('/api/workflows/submit', {
134
+ yaml,
135
+ ...(channel ? { channel } : {}),
136
+ prompt,
137
+ });
138
+ } catch (err) {
139
+ reportAndExit(err, 'crosstalk workflow compose (submit)');
140
+ }
141
+ if (submitted.relPath) {
142
+ process.stdout.write(`Workflow dispatched: ${submitted.relPath}\n`);
143
+ }
144
+ if (submitted.childChannel) {
145
+ process.stdout.write(`Child channel: ${submitted.childChannel.slice(0, 8)} (parent: ${(submitted.channel || '').slice(0, 8)})\n`);
146
+ }
147
+ return 0;
148
+ }
149
+
150
+ async function runRun(argv) {
151
+ const pos = positionals(argv, [
152
+ '--channel', '--from', '--containername', '-c',
153
+ '--profile', '--server',
154
+ ]);
155
+ const file = pos[0];
156
+ if (!file) {
157
+ process.stderr.write('crosstalk workflow run: <file> required.\n');
158
+ return 1;
159
+ }
160
+ if (!existsSync(file)) {
161
+ process.stderr.write(`crosstalk workflow run: file not found — ${file}\n`);
162
+ return 1;
163
+ }
164
+ const body = readFileSync(file, 'utf-8');
165
+ if (body.length === 0) {
166
+ process.stderr.write('crosstalk workflow run: file is empty.\n');
167
+ return 1;
168
+ }
169
+
170
+ const api = apiFor(argv);
171
+ const payload = {
172
+ type: 'workflow',
173
+ channel: flag(argv, '--channel'),
174
+ from: flag(argv, '--from') ?? process.env.USER ?? 'operator',
175
+ body,
176
+ };
177
+ let resp;
178
+ try {
179
+ resp = await api.post('/messages', payload);
180
+ } catch (err) {
181
+ reportAndExit(err, 'crosstalk workflow run');
182
+ }
183
+ process.stdout.write(`Workflow dispatched: ${resp.relPath}\n`);
184
+ process.stdout.write(`Child channel: ${(resp.childChannel || '').slice(0, 8)} (parent: ${(resp.channel || '').slice(0, 8)})\n`);
185
+ return 0;
186
+ }
187
+
188
+ async function runStatus(argv) {
189
+ const api = apiFor(argv);
190
+ const id = flag(argv, '--id');
191
+ if (id) {
192
+ let r;
193
+ try { r = await api.get(`/api/workflows/${encodeURIComponent(id)}`); }
194
+ catch (err) { reportAndExit(err, 'crosstalk workflow status'); }
195
+ printDetail(r);
196
+ return 0;
197
+ }
198
+ let r;
199
+ try { r = await api.get('/api/workflows'); }
200
+ catch (err) { reportAndExit(err, 'crosstalk workflow status'); }
201
+ const list = r.workflows || [];
202
+ if (list.length === 0) {
203
+ process.stdout.write('(no workflows on this transport)\n');
204
+ return 0;
205
+ }
206
+ for (const w of list) printSummaryLine(w);
207
+ return 0;
208
+ }
209
+
210
+ function printSummaryLine(w) {
211
+ const id = (w.childUuid || '').slice(0, 8);
212
+ const parent = (w.parentName || (w.parentUuid || '').slice(0, 8) || '-');
213
+ const phase = w.phase || (w.isComplete ? 'complete' : 'in-flight');
214
+ const fan = w.fanoutDispatched != null
215
+ ? `${w.fanoutReplied}/${w.fanoutDispatched} replies`
216
+ : '';
217
+ process.stdout.write(` ${id} ${parent.padEnd(20)} ${phase.padEnd(12)} ${fan}\n`);
218
+ }
219
+
220
+ function printDetail(d) {
221
+ if (!d) {
222
+ process.stdout.write('(no detail)\n');
223
+ return;
224
+ }
225
+ const lines = [];
226
+ if (d.childUuid) lines.push(`uuid: ${d.childUuid}`);
227
+ if (d.parentUuid) lines.push(`parent: ${d.parentUuid}`);
228
+ if (d.markerRelPath) lines.push(`marker: ${d.markerRelPath}`);
229
+ if (d.phase) lines.push(`phase: ${d.phase}`);
230
+ if (d.fanoutDispatched != null) lines.push(`fanout: ${d.fanoutReplied}/${d.fanoutDispatched} replies`);
231
+ if (d.synthesizeStatus) lines.push(`synth: ${d.synthesizeStatus}`);
232
+ if (d.isComplete != null) lines.push(`complete: ${d.isComplete}`);
233
+ process.stdout.write(lines.join('\n') + '\n');
234
+ }
@@ -0,0 +1,62 @@
1
+ # crosstalk@<transport>.service — systemd template for v8-native system mode.
2
+ #
3
+ # Install:
4
+ # sudo deploy/install.sh
5
+ # sudo systemctl daemon-reload
6
+ # sudo systemctl enable --now crosstalk@main
7
+ #
8
+ # Instances: one per named transport. The instance name (the `<name>` in
9
+ # `crosstalk@<name>`) becomes the storage subdirectory under /var/lib/
10
+ # crosstalk and the dispatcher alias the engine boots with.
11
+ #
12
+ # alpha.18 monorepo: the daemon is now an internal subcommand of the
13
+ # same `crosstalk` CLI — `crosstalk daemon dispatch …` replaces the
14
+ # dropped `crosstalkd` bin.
15
+ #
16
+ # Prereqs (handled by deploy/install.sh):
17
+ # - 'crosstalk' system user + group exist
18
+ # - /var/lib/crosstalk/<name>/ exists (crosstalk:crosstalk 0750)
19
+ # - /var/log/crosstalk/ exists (crosstalk:crosstalk 0750)
20
+ # - The transport for <name> has been initialized via
21
+ # `sudo -u crosstalk crosstalk transport init --containername <name>`
22
+
23
+ [Unit]
24
+ Description=crosstalk engine (transport: %i)
25
+ Documentation=https://github.com/cordfuse/crosstalk
26
+ After=network.target
27
+ Wants=network-online.target
28
+
29
+ [Service]
30
+ Type=simple
31
+ User=crosstalk
32
+ Group=crosstalk
33
+ Environment=CROSSTALK_USER_MODE=
34
+ Environment=DISPATCH_POLL_SECONDS=30
35
+ Environment=DISPATCH_JSON=true
36
+ WorkingDirectory=/var/lib/crosstalk/%i/transport
37
+ ExecStart=/usr/bin/crosstalk daemon dispatch --alias %i --poll ${DISPATCH_POLL_SECONDS} --json
38
+ Restart=on-failure
39
+ RestartSec=5
40
+ StandardOutput=append:/var/log/crosstalk/%i.log
41
+ StandardError=append:/var/log/crosstalk/%i.log
42
+
43
+ # Hardening — daemon needs nothing outside its own state + transport dirs.
44
+ # Mirrors @cordfuse/llmux v2's llmuxd.service profile.
45
+ NoNewPrivileges=true
46
+ ProtectSystem=strict
47
+ ProtectHome=true
48
+ ReadWritePaths=/var/lib/crosstalk /var/log/crosstalk
49
+ PrivateTmp=true
50
+ PrivateDevices=true
51
+ ProtectKernelTunables=true
52
+ ProtectKernelModules=true
53
+ ProtectControlGroups=true
54
+ RestrictSUIDSGID=true
55
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
56
+ SystemCallFilter=@system-service
57
+ SystemCallErrorNumber=EPERM
58
+ LimitNOFILE=65536
59
+ CapabilityBoundingSet=
60
+
61
+ [Install]
62
+ WantedBy=multi-user.target
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+ # crosstalk v8-native system-mode install.
3
+ #
4
+ # Run as root. Sets up the `crosstalk` system user + group, /etc + /var
5
+ # directories, the systemd template unit, and points operators at the
6
+ # next step (initializing a transport + enabling its instance).
7
+ #
8
+ # Mirrors the @cordfuse/llmux v2 install pattern so Cordfuse family
9
+ # deployments use the same shape: npm-install the binary globally,
10
+ # run this script to set up the OS-level scaffolding, then enable
11
+ # the systemd unit instance per named transport.
12
+
13
+ set -euo pipefail
14
+
15
+ # ─── prerequisite checks ─────────────────────────────────────────────────
16
+ if [ "${EUID:-$(id -u)}" -ne 0 ]; then
17
+ echo "error: this installer must run as root" >&2
18
+ exit 1
19
+ fi
20
+ if ! command -v systemctl >/dev/null 2>&1; then
21
+ echo "error: systemd not detected. v8-native system mode currently requires systemd." >&2
22
+ echo " For non-systemd hosts, run user mode: CROSSTALK_USER_MODE=1 crosstalk server start" >&2
23
+ exit 1
24
+ fi
25
+ if ! command -v crosstalk >/dev/null 2>&1; then
26
+ echo "error: crosstalk binary not on PATH." >&2
27
+ echo " Install first: npm install -g @cordfuse/crosstalk" >&2
28
+ exit 1
29
+ fi
30
+
31
+ # ─── service user + group ────────────────────────────────────────────────
32
+ if ! getent group crosstalk >/dev/null; then
33
+ groupadd --system crosstalk
34
+ echo "created group: crosstalk"
35
+ fi
36
+ if ! id crosstalk >/dev/null 2>&1; then
37
+ # --home-dir /var/lib/crosstalk: agent CLI auth tokens (~/.claude/,
38
+ # ~/.codex/, ~/.gemini/) live under the service user's home. Daemon
39
+ # never reaches into /home/* — same $HOME-principle as llmux v2.
40
+ useradd --system --gid crosstalk --home-dir /var/lib/crosstalk \
41
+ --shell /usr/sbin/nologin --comment "crosstalk engine daemon" crosstalk
42
+ echo "created user: crosstalk"
43
+ fi
44
+
45
+ # ─── directories ─────────────────────────────────────────────────────────
46
+ install -d -o root -g crosstalk -m 0750 /etc/crosstalk
47
+ install -d -o crosstalk -g crosstalk -m 0750 /var/lib/crosstalk
48
+ install -d -o crosstalk -g crosstalk -m 0750 /var/log/crosstalk
49
+ # Per-transport state dirs (e.g., /var/lib/crosstalk/main/) are created on
50
+ # first `crosstalk transport init --containername <name>` run by the
51
+ # service user.
52
+
53
+ # ─── systemd unit ────────────────────────────────────────────────────────
54
+ SOURCE_DIR="$(cd "$(dirname "$0")" && pwd)"
55
+ install -o root -g root -m 0644 \
56
+ "$SOURCE_DIR/crosstalk@.service" \
57
+ /etc/systemd/system/crosstalk@.service
58
+
59
+ systemctl daemon-reload
60
+ echo "installed: /etc/systemd/system/crosstalk@.service"
61
+
62
+ # ─── next steps ──────────────────────────────────────────────────────────
63
+ cat <<'EOF'
64
+
65
+ crosstalk v8-native system-mode install complete.
66
+
67
+ Initialize a transport (system mode is the default now; no env var needed):
68
+
69
+ sudo -u crosstalk crosstalk transport init --containername main
70
+
71
+ Then enable + start the systemd instance for it:
72
+
73
+ sudo systemctl enable --now crosstalk@main
74
+ sudo systemctl status crosstalk@main
75
+ sudo journalctl -u crosstalk@main -f # or: tail -f /var/log/crosstalk/main.log
76
+
77
+ To run additional named transports, repeat with a different name:
78
+
79
+ sudo -u crosstalk crosstalk transport init --containername staging
80
+ sudo systemctl enable --now crosstalk@staging
81
+
82
+ EOF
package/lib/api-client.js CHANGED
@@ -1,27 +1,32 @@
1
- // api-client.js — HTTP client over loopback to crosstalkd's local API.
1
+ // api-client.js — HTTP client to a crosstalk engine.
2
2
  //
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
- // 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.
3
+ // Two routing modes:
4
+ // 1. **Local (default)** engine binds 127.0.0.1 on the host. Port
5
+ // is per-transport (default 'crosstalk' transport gets 7000; named
6
+ // transports record their port in `<base>/<name>/api-port`).
7
+ // 2. **Remote** active credentials profile carries a non-loopback
8
+ // `server` URL (e.g. https://crosstalk.cordfuse.io). When set, the
9
+ // profile server overrides loopback routing for this invocation.
8
10
  //
9
- // Port resolution order:
11
+ // Port resolution order (local mode only):
10
12
  // 1. opts.port (explicit override from caller)
11
13
  // 2. CROSSTALK_API_PORT env var (test/dev override)
12
14
  // 3. apiPortFor(name) — reads `<base>/<name>/api-port` file
13
15
  // 4. DEFAULT_API_PORT (7000) as last resort
14
16
  //
15
- // No auth: engine binds 127.0.0.1 only, no token. Same model as ollama.
17
+ // Auth: bearer token from the active credentials profile, sent on every
18
+ // authenticated call. opts.skipAuth=true suppresses (used by login).
16
19
  //
17
20
  // Version skew: engine sets X-Crosstalk-Engine-Version on every response.
18
21
  // We compare to client version on each call, emit one warning per process
19
22
  // if mismatched. Pre-1.0 client and engine ship in lockstep.
20
23
 
21
- import { readFileSync } from 'fs';
24
+ import { readFileSync, existsSync } from 'fs';
22
25
  import { dirname, join } from 'path';
26
+ import { homedir } from 'os';
23
27
  import { fileURLToPath } from 'url';
24
28
  import { apiPortFor, DEFAULT_API_PORT, parseContainerName } from './resolve.js';
29
+ import { readToken, getActiveProfile } from './credentials.js';
25
30
 
26
31
  const CLIENT_VERSION = (() => {
27
32
  try {
@@ -59,6 +64,12 @@ function apiUrl(path, port) {
59
64
  return `http://127.0.0.1:${port}${path}`;
60
65
  }
61
66
 
67
+ function remoteApiUrl(server, path) {
68
+ // Trim any trailing slash so we don't end up with double-slashes.
69
+ const base = server.replace(/\/+$/, '');
70
+ return `${base}${path}`;
71
+ }
72
+
62
73
  async function parseJsonOrThrow(res) {
63
74
  let body;
64
75
  try {
@@ -82,28 +93,64 @@ export class ApiError extends Error {
82
93
  }
83
94
 
84
95
  export class ConnectError extends Error {
85
- constructor(port, cause) {
96
+ constructor(target, cause) {
97
+ const isUrl = typeof target === 'string' && /^https?:/.test(target);
86
98
  super(
87
- `cannot reach crosstalkd on 127.0.0.1:${port} — is the container running? ` +
88
- `Try 'crosstalk up' to start it. (underlying: ${cause})`,
99
+ isUrl
100
+ ? `cannot reach crosstalk engine at ${target}. (underlying: ${cause})`
101
+ : `cannot reach crosstalk engine on 127.0.0.1:${target} — is the daemon running? ` +
102
+ `Try 'crosstalk server start' to start it. (underlying: ${cause})`,
89
103
  );
90
104
  this.name = 'ConnectError';
91
- this.port = port;
105
+ this.target = target;
106
+ this.port = isUrl ? null : target;
92
107
  }
93
108
  }
94
109
 
95
110
  async function call(method, path, body, opts = {}) {
96
- const port = resolvePort(opts);
111
+ // Resolve the target server, in priority order:
112
+ // 1. opts.serverOverride — explicit per-call URL. Used by `auth login`
113
+ // so a new --server flag doesn't get shadowed by an existing profile.
114
+ // 2. active credentials profile's `server` field — the steady-state
115
+ // path: the profile chosen at login carries the engine URL.
116
+ // 3. resolvePort(opts) — fallback for pre-login state (`crosstalk
117
+ // version` from a fresh install).
118
+ const profile = getActiveProfile(opts.argv || []);
119
+
120
+ let target, url;
121
+ if (opts.serverOverride) {
122
+ target = opts.serverOverride;
123
+ url = remoteApiUrl(opts.serverOverride, path);
124
+ } else if (profile && profile.server) {
125
+ target = profile.server;
126
+ url = remoteApiUrl(profile.server, path);
127
+ } else {
128
+ const port = resolvePort(opts);
129
+ target = port;
130
+ url = apiUrl(path, port);
131
+ }
132
+
133
+ // v8 uniform-auth: read the CLI's bearer token from the active
134
+ // credentials profile and send it on every call. If missing, the
135
+ // engine will 401 with a hint pointing at `crosstalk auth login`.
136
+ // Callers can pass opts.skipAuth=true to suppress this (used by the
137
+ // login command itself, which doesn't yet have a token).
138
+ const headers = {};
139
+ if (body) headers['Content-Type'] = 'application/json';
140
+ if (!opts.skipAuth) {
141
+ const token = readToken(opts.argv || []);
142
+ if (token) headers['Authorization'] = `Bearer ${token}`;
143
+ }
97
144
  const init = {
98
145
  method,
99
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
146
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
100
147
  body: body ? JSON.stringify(body) : undefined,
101
148
  };
102
149
  let res;
103
150
  try {
104
- res = await fetch(apiUrl(path, port), init);
151
+ res = await fetch(url, init);
105
152
  } catch (err) {
106
- throw new ConnectError(port, err.message || String(err));
153
+ throw new ConnectError(target, err.message || String(err));
107
154
  }
108
155
  checkSkew(res.headers.get('x-crosstalk-engine-version'));
109
156
  return parseJsonOrThrow(res);
@@ -113,7 +160,7 @@ async function call(method, path, body, opts = {}) {
113
160
  // either pass argv (auto-resolves name) or an explicit name. Internally
114
161
  // each call resolves the port via the name's api-port file. Validation
115
162
  // errors print cleanly (no stack trace) — bug C fix.
116
- export function apiFor(argv) {
163
+ export function apiFor(argv, baseOpts = {}) {
117
164
  let name;
118
165
  try {
119
166
  name = parseContainerName(argv);
@@ -121,12 +168,20 @@ export function apiFor(argv) {
121
168
  process.stderr.write(`crosstalk: ${err.message}\n`);
122
169
  process.exit(1);
123
170
  }
171
+ // argv is threaded into every call so the profile resolver can
172
+ // honour `--profile <name>` overrides per-invocation. baseOpts is
173
+ // merged into every call — used by `auth login` to pass
174
+ // serverOverride when the profile/argv would otherwise route to the
175
+ // wrong engine.
124
176
  return {
125
177
  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 }),
178
+ get: (path, opts = {}) => call('GET', path, undefined, { ...baseOpts, ...opts, name, argv }),
179
+ post: (path, body, opts = {}) => call('POST', path, body, { ...baseOpts, ...opts, name, argv }),
180
+ patch: (path, body, opts = {}) => call('PATCH', path, body, { ...baseOpts, ...opts, name, argv }),
181
+ delete: (path, opts = {}) => call('DELETE', path, undefined, { ...baseOpts, ...opts, name, argv }),
182
+ // Unauthenticated variants used by `crosstalk auth login` — it
183
+ // doesn't have a token yet when calling POST /api/auth/login.
184
+ postNoAuth: (path, body, opts = {}) => call('POST', path, body, { ...baseOpts, ...opts, name, argv, skipAuth: true }),
130
185
  };
131
186
  }
132
187