@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.
- package/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +402 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +270 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +173 -0
- package/lib/resolve.js +101 -34
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +190 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -115
- 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
|
|
1
|
+
// api-client.js — HTTP client to a crosstalk engine.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
96
|
+
constructor(target, cause) {
|
|
97
|
+
const isUrl = typeof target === 'string' && /^https?:/.test(target);
|
|
86
98
|
super(
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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(
|
|
151
|
+
res = await fetch(url, init);
|
|
105
152
|
} catch (err) {
|
|
106
|
-
throw new ConnectError(
|
|
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
|
|