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