@cordfuse/crosstalk 7.0.0-alpha.9 → 8.0.0-alpha.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 +422 -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 +185 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +299 -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 +182 -0
- package/lib/resolve.js +106 -37
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1717 -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 +530 -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 +300 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +191 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +353 -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 +130 -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
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
|
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// credentials.js — CLI-side persisted credentials (multi-profile).
|
|
2
|
+
//
|
|
3
|
+
// Replaces v7's `auth-token.js` (single-token at cli-token). Stores
|
|
4
|
+
// the active profile + all known profiles in a single JSON file. Each
|
|
5
|
+
// profile carries its own server URL + bearer token, so the same CLI
|
|
6
|
+
// can talk to multiple crosstalk engines (e.g. local loopback +
|
|
7
|
+
// hosted prod).
|
|
8
|
+
//
|
|
9
|
+
// Path: $XDG_CONFIG_HOME/crosstalk/credentials.json, falling back to
|
|
10
|
+
// $HOME/.config/crosstalk/credentials.json. File mode 0600.
|
|
11
|
+
//
|
|
12
|
+
// Shape:
|
|
13
|
+
// {
|
|
14
|
+
// "activeProfile": "default",
|
|
15
|
+
// "profiles": {
|
|
16
|
+
// "default": {
|
|
17
|
+
// "server": "http://127.0.0.1:7000",
|
|
18
|
+
// "username": "stevekrisjanovs",
|
|
19
|
+
// "token": "sas_<id>.<secret>",
|
|
20
|
+
// "tokenId": "<id>",
|
|
21
|
+
// "createdAt": "2026-06-22T16:54:00Z"
|
|
22
|
+
// }
|
|
23
|
+
// }
|
|
24
|
+
// }
|
|
25
|
+
//
|
|
26
|
+
// Legacy migration: on first read, if a v7-era `cli-token` file exists
|
|
27
|
+
// and `credentials.json` does not, the single token is ingested as the
|
|
28
|
+
// `default` profile (server = loopback default) and the legacy file is
|
|
29
|
+
// removed. Migration is best-effort; if it fails the engine just
|
|
30
|
+
// 401s and the operator re-runs `auth login`.
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
existsSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
renameSync,
|
|
37
|
+
unlinkSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
chmodSync,
|
|
40
|
+
} from 'fs';
|
|
41
|
+
import { join } from 'path';
|
|
42
|
+
import { homedir } from 'os';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_LOOPBACK_SERVER = 'http://127.0.0.1:7000';
|
|
45
|
+
|
|
46
|
+
function configDir() {
|
|
47
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
48
|
+
return join(base, 'crosstalk');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function credentialsPath() {
|
|
52
|
+
return join(configDir(), 'credentials.json');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function legacyTokenPath() {
|
|
56
|
+
return join(configDir(), 'cli-token');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function emptyCredentials() {
|
|
60
|
+
return { activeProfile: null, profiles: {} };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readJsonOrEmpty(path) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(path, 'utf-8');
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
if (!parsed || typeof parsed !== 'object') return emptyCredentials();
|
|
68
|
+
if (!parsed.profiles || typeof parsed.profiles !== 'object') parsed.profiles = {};
|
|
69
|
+
return parsed;
|
|
70
|
+
} catch {
|
|
71
|
+
return emptyCredentials();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let migrationAttempted = false;
|
|
76
|
+
function maybeMigrate() {
|
|
77
|
+
if (migrationAttempted) return;
|
|
78
|
+
migrationAttempted = true;
|
|
79
|
+
const credPath = credentialsPath();
|
|
80
|
+
const legacyPath = legacyTokenPath();
|
|
81
|
+
if (existsSync(credPath)) return;
|
|
82
|
+
if (!existsSync(legacyPath)) return;
|
|
83
|
+
let token;
|
|
84
|
+
try {
|
|
85
|
+
token = readFileSync(legacyPath, 'utf-8').trim();
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!token) return;
|
|
90
|
+
const cred = emptyCredentials();
|
|
91
|
+
cred.activeProfile = 'default';
|
|
92
|
+
cred.profiles.default = {
|
|
93
|
+
server: DEFAULT_LOOPBACK_SERVER,
|
|
94
|
+
username: null,
|
|
95
|
+
token,
|
|
96
|
+
tokenId: null,
|
|
97
|
+
createdAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
writeCredentials(cred);
|
|
100
|
+
try { unlinkSync(legacyPath); } catch { /* best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readCredentials() {
|
|
104
|
+
maybeMigrate();
|
|
105
|
+
return readJsonOrEmpty(credentialsPath());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function writeCredentials(cred) {
|
|
109
|
+
const dir = configDir();
|
|
110
|
+
mkdirSync(dir, { recursive: true });
|
|
111
|
+
const path = credentialsPath();
|
|
112
|
+
const tmp = `${path}.tmp`;
|
|
113
|
+
writeFileSync(tmp, JSON.stringify(cred, null, 2) + '\n', { mode: 0o600 });
|
|
114
|
+
try { chmodSync(tmp, 0o600); } catch { /* best-effort */ }
|
|
115
|
+
renameSync(tmp, path);
|
|
116
|
+
return path;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function credentialsFilePath() {
|
|
120
|
+
return credentialsPath();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function profileNameOverride(argv) {
|
|
124
|
+
if (!Array.isArray(argv)) return null;
|
|
125
|
+
for (let i = 0; i < argv.length - 1; i++) {
|
|
126
|
+
if (argv[i] === '--profile') return argv[i + 1];
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function getActiveProfileName(argv = []) {
|
|
132
|
+
const override = profileNameOverride(argv);
|
|
133
|
+
if (override) return override;
|
|
134
|
+
const cred = readCredentials();
|
|
135
|
+
return cred.activeProfile || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getActiveProfile(argv = []) {
|
|
139
|
+
const name = getActiveProfileName(argv);
|
|
140
|
+
if (!name) return null;
|
|
141
|
+
const cred = readCredentials();
|
|
142
|
+
return cred.profiles[name] || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function listProfiles() {
|
|
146
|
+
const cred = readCredentials();
|
|
147
|
+
return Object.entries(cred.profiles).map(([name, p]) => ({
|
|
148
|
+
name,
|
|
149
|
+
server: p.server,
|
|
150
|
+
username: p.username,
|
|
151
|
+
tokenId: p.tokenId,
|
|
152
|
+
active: cred.activeProfile === name,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function setActiveProfile(name) {
|
|
157
|
+
const cred = readCredentials();
|
|
158
|
+
if (!cred.profiles[name]) {
|
|
159
|
+
throw new Error(`profile '${name}' not found`);
|
|
160
|
+
}
|
|
161
|
+
cred.activeProfile = name;
|
|
162
|
+
writeCredentials(cred);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function saveProfile(name, data) {
|
|
166
|
+
const cred = readCredentials();
|
|
167
|
+
cred.profiles[name] = {
|
|
168
|
+
server: data.server || DEFAULT_LOOPBACK_SERVER,
|
|
169
|
+
username: data.username ?? null,
|
|
170
|
+
token: data.token,
|
|
171
|
+
tokenId: data.tokenId ?? null,
|
|
172
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
173
|
+
};
|
|
174
|
+
if (!cred.activeProfile) cred.activeProfile = name;
|
|
175
|
+
writeCredentials(cred);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function deleteProfile(name) {
|
|
179
|
+
const cred = readCredentials();
|
|
180
|
+
if (!cred.profiles[name]) return false;
|
|
181
|
+
delete cred.profiles[name];
|
|
182
|
+
if (cred.activeProfile === name) {
|
|
183
|
+
const remaining = Object.keys(cred.profiles);
|
|
184
|
+
cred.activeProfile = remaining.length > 0 ? remaining[0] : null;
|
|
185
|
+
}
|
|
186
|
+
writeCredentials(cred);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function deleteActiveProfile() {
|
|
191
|
+
const cred = readCredentials();
|
|
192
|
+
const name = cred.activeProfile;
|
|
193
|
+
if (!name) return null;
|
|
194
|
+
delete cred.profiles[name];
|
|
195
|
+
const remaining = Object.keys(cred.profiles);
|
|
196
|
+
cred.activeProfile = remaining.length > 0 ? remaining[0] : null;
|
|
197
|
+
writeCredentials(cred);
|
|
198
|
+
return name;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Back-compat wrapper preserved so `lib/api-client.js` can keep its
|
|
202
|
+
// readToken()-shaped call site without knowing about profiles. The
|
|
203
|
+
// active profile drives the token; absent active profile → null.
|
|
204
|
+
export function readToken(argv = []) {
|
|
205
|
+
const p = getActiveProfile(argv);
|
|
206
|
+
return p ? p.token : null;
|
|
207
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// nativeServer.js — host-process control for the crosstalk engine.
|
|
2
|
+
//
|
|
3
|
+
// v8-native replaces the docker lifecycle. The engine main loop is
|
|
4
|
+
// `crosstalk daemon dispatch …`, spawned as a detached child of the
|
|
5
|
+
// operator's host shell. PID is recorded in crosstalk-state/crosstalk.pid
|
|
6
|
+
// and stdio is redirected to crosstalk-state/crosstalk.log.
|
|
7
|
+
//
|
|
8
|
+
// The daemon is an internal subcommand of the `crosstalk` CLI;
|
|
9
|
+
// startEngine() spawns `process.execPath bin/crosstalk.js daemon dispatch …`
|
|
10
|
+
// against the package's own bin script, resolved relative to this file.
|
|
11
|
+
//
|
|
12
|
+
// Trust + isolation notes:
|
|
13
|
+
// - User mode (default): engine inherits the operator's $HOME, so
|
|
14
|
+
// agent CLI OAuth tokens land in ~/.claude, ~/.codex, etc. — the
|
|
15
|
+
// same places the operator's interactive sessions use. Simpler and
|
|
16
|
+
// less surprising than the v7 container's CROSSTALK_HOME isolation.
|
|
17
|
+
// - System mode: engine runs as the `crosstalk` system user (per
|
|
18
|
+
// systemd unit), with $HOME set to /var/lib/crosstalk so agents
|
|
19
|
+
// write their state under the service user, not /home/*.
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'child_process';
|
|
22
|
+
import { openSync, existsSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
|
23
|
+
import { dirname, join, resolve } from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
import { storagePaths, apiPortFor, runningPid } from './resolve.js';
|
|
26
|
+
|
|
27
|
+
const _thisDir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the path to this package's own `crosstalk` CLI entrypoint.
|
|
31
|
+
* The daemon is now `crosstalk daemon dispatch …`, so startEngine
|
|
32
|
+
* re-invokes the very script that called it — using process.execPath
|
|
33
|
+
* (the running Node) + this bin path.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSelfBin() {
|
|
36
|
+
return resolve(_thisDir, '..', 'bin', 'crosstalk.js');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start the crosstalk engine (`crosstalk daemon dispatch`) as a detached host process for the given transport.
|
|
41
|
+
* Returns { ok: true, pid } on success, { ok: false, error } otherwise.
|
|
42
|
+
*
|
|
43
|
+
* Behavior:
|
|
44
|
+
* - Refuses if already running (use stop/restart instead).
|
|
45
|
+
* - Creates the state dir if missing.
|
|
46
|
+
* - Opens the log file in append mode; stdin/stdout/stderr all go there.
|
|
47
|
+
* - Spawns detached so the parent can exit and the engine keeps running.
|
|
48
|
+
* - Verifies the PID is alive after a short delay (~150ms) before returning.
|
|
49
|
+
*/
|
|
50
|
+
export function startEngine(name, opts = {}) {
|
|
51
|
+
const paths = storagePaths(name);
|
|
52
|
+
if (runningPid(name)) {
|
|
53
|
+
return { ok: false, error: `'${name}' is already running (pid ${runningPid(name)})` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const selfBin = resolveSelfBin();
|
|
57
|
+
if (!existsSync(selfBin)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: `crosstalk CLI entry missing at ${selfBin} — package install looks broken`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
mkdirSync(paths.crosstalkState, { recursive: true });
|
|
65
|
+
|
|
66
|
+
const alias = opts.alias ?? process.env.CROSSTALK_ALIAS ?? name;
|
|
67
|
+
const poll = String(opts.pollSeconds ?? process.env.DISPATCH_POLL_SECONDS ?? 30);
|
|
68
|
+
const port = String(apiPortFor(name));
|
|
69
|
+
|
|
70
|
+
// --path: the transport lives at an arbitrary directory (e.g. a mounted
|
|
71
|
+
// share). Point the engine there via CROSSTALK_TRANSPORT_PATH, and pin
|
|
72
|
+
// state machine-local (by name) so cursor/heartbeat never land on the
|
|
73
|
+
// share — each machine sharing the dir keeps its own local state.
|
|
74
|
+
const transportDir = opts.transportPath ? resolve(opts.transportPath) : paths.transportDir;
|
|
75
|
+
const env = {
|
|
76
|
+
...process.env,
|
|
77
|
+
CROSSTALK_API_PORT: port,
|
|
78
|
+
TRANSPORT_DIR: transportDir,
|
|
79
|
+
};
|
|
80
|
+
if (opts.transportPath) {
|
|
81
|
+
env.CROSSTALK_TRANSPORT_PATH = transportDir;
|
|
82
|
+
env.CROSSTALK_STATE_DIR = paths.crosstalkState;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const logFd = openSync(paths.logFile, 'a');
|
|
86
|
+
const child = spawn(
|
|
87
|
+
process.execPath,
|
|
88
|
+
[selfBin, 'daemon', 'dispatch', '--alias', alias, '--poll', poll, '--json'],
|
|
89
|
+
{
|
|
90
|
+
cwd: transportDir,
|
|
91
|
+
env,
|
|
92
|
+
stdio: ['ignore', logFd, logFd],
|
|
93
|
+
detached: true,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
if (!child.pid) {
|
|
97
|
+
return { ok: false, error: 'spawn failed to allocate a pid' };
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(paths.pidFile, String(child.pid) + '\n');
|
|
100
|
+
child.unref(); // let parent exit without waiting on the child
|
|
101
|
+
|
|
102
|
+
// Brief verify the child didn't die immediately (missing transport
|
|
103
|
+
// dir, broken tsx resolution, etc.). 150ms is enough to catch
|
|
104
|
+
// immediate ENOENT-class failures without making `server start`
|
|
105
|
+
// feel sluggish.
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
while (Date.now() - start < 150) {
|
|
108
|
+
try { process.kill(child.pid, 0); } catch {
|
|
109
|
+
return { ok: false, error: 'engine exited immediately — check logs at ' + paths.logFile };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, pid: child.pid, logFile: paths.logFile, port: Number(port) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stop a running crosstalk engine. SIGTERM first, then SIGKILL after a grace
|
|
117
|
+
* period (default 5s). Returns { ok, wasRunning, killed? }.
|
|
118
|
+
*
|
|
119
|
+
* `killed: true` indicates SIGKILL was required (clean shutdown failed).
|
|
120
|
+
*/
|
|
121
|
+
export function stopEngine(name, opts = {}) {
|
|
122
|
+
const paths = storagePaths(name);
|
|
123
|
+
const pid = runningPid(name);
|
|
124
|
+
if (!pid) {
|
|
125
|
+
// No running engine; mop up any stale pid file and report idempotent success.
|
|
126
|
+
if (existsSync(paths.pidFile)) {
|
|
127
|
+
try { writeFileSync(paths.pidFile, ''); } catch { /* best effort */ }
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, wasRunning: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const graceMs = (opts.graceSeconds ?? 5) * 1000;
|
|
133
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
134
|
+
|
|
135
|
+
const deadline = Date.now() + graceMs;
|
|
136
|
+
let killed = false;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
try { process.kill(pid, 0); } catch {
|
|
139
|
+
// Process is gone — clean exit.
|
|
140
|
+
try { writeFileSync(paths.pidFile, ''); } catch {}
|
|
141
|
+
return { ok: true, wasRunning: true, pid };
|
|
142
|
+
}
|
|
143
|
+
// Tight spin — 50ms granularity is fine for a 5s window.
|
|
144
|
+
const until = Date.now() + 50;
|
|
145
|
+
while (Date.now() < until) { /* idle */ }
|
|
146
|
+
}
|
|
147
|
+
// Timed out. Hard-kill.
|
|
148
|
+
try { process.kill(pid, 'SIGKILL'); killed = true; } catch { /* race: gone */ }
|
|
149
|
+
try { writeFileSync(paths.pidFile, ''); } catch {}
|
|
150
|
+
return { ok: true, wasRunning: true, pid, killed };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Snapshot the engine's status for the given transport. Returns
|
|
155
|
+
* { running, pid?, port?, uptimeSeconds?, apiReachable? }.
|
|
156
|
+
*
|
|
157
|
+
* apiReachable requires an HTTP probe; pass { probeApi: true } to opt in.
|
|
158
|
+
*/
|
|
159
|
+
export async function engineStatus(name, opts = {}) {
|
|
160
|
+
const paths = storagePaths(name);
|
|
161
|
+
const pid = runningPid(name);
|
|
162
|
+
if (!pid) return { running: false };
|
|
163
|
+
|
|
164
|
+
let uptimeSeconds;
|
|
165
|
+
try {
|
|
166
|
+
const stat = statSync(`/proc/${pid}/stat`);
|
|
167
|
+
uptimeSeconds = Math.round((Date.now() - stat.ctimeMs) / 1000);
|
|
168
|
+
} catch { /* /proc not available on macOS — leave undefined */ }
|
|
169
|
+
|
|
170
|
+
const port = apiPortFor(name);
|
|
171
|
+
const out = { running: true, pid, port, ...(uptimeSeconds !== undefined ? { uptimeSeconds } : {}) };
|
|
172
|
+
|
|
173
|
+
if (opts.probeApi) {
|
|
174
|
+
try {
|
|
175
|
+
const resp = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(2000) });
|
|
176
|
+
out.apiReachable = resp.ok;
|
|
177
|
+
} catch {
|
|
178
|
+
out.apiReachable = false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|