@craftspace/cli 0.9.1 → 0.10.0
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/dist/index.js +245 -132
- package/dist/machine.d.ts +30 -5
- package/dist/machine.js +230 -113
- package/dist/probe.d.ts +2 -1
- package/dist/probe.js +5 -4
- package/package.json +1 -1
package/dist/machine.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type Machine } from '@craftspace/shared';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
export declare const machine: {
|
|
3
|
-
|
|
4
|
+
root(): string;
|
|
5
|
+
seats(): Promise<Seat[]>;
|
|
4
6
|
login({ token, url, install }: {
|
|
5
7
|
token: string;
|
|
6
8
|
url: string;
|
|
7
9
|
install: boolean;
|
|
8
|
-
}): Promise<
|
|
9
|
-
workDir(): Promise<string
|
|
10
|
+
}): Promise<SignedIn>;
|
|
11
|
+
workDir(organization: string): Promise<string>;
|
|
10
12
|
controlPlane(override?: string): Promise<string>;
|
|
11
13
|
signIn({ url, why, install }: {
|
|
12
14
|
url?: string;
|
|
@@ -16,8 +18,13 @@ export declare const machine: {
|
|
|
16
18
|
status(): Promise<{
|
|
17
19
|
machine: Machine;
|
|
18
20
|
url: string;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
organization: string;
|
|
22
|
+
}[]>;
|
|
23
|
+
beatAll(say?: (line: string) => void): Promise<boolean>;
|
|
24
|
+
beatOnce({ home, say }: {
|
|
25
|
+
home: string;
|
|
26
|
+
say?: (line: string) => void;
|
|
27
|
+
}): Promise<boolean>;
|
|
21
28
|
beatForever(): Promise<void>;
|
|
22
29
|
run(): Promise<number>;
|
|
23
30
|
daemon(): Promise<{
|
|
@@ -28,6 +35,12 @@ export declare const machine: {
|
|
|
28
35
|
};
|
|
29
36
|
export declare function portableTerm(local: string | undefined): string;
|
|
30
37
|
export declare function tokenIn(line: string): string | undefined;
|
|
38
|
+
export declare function replaceBlock({ text, begin, end, block, }: {
|
|
39
|
+
text: string;
|
|
40
|
+
begin: string;
|
|
41
|
+
end: string;
|
|
42
|
+
block: string;
|
|
43
|
+
}): string;
|
|
31
44
|
export declare function nextDelayMs(failures: number, busy?: boolean): number;
|
|
32
45
|
interface Auth {
|
|
33
46
|
url: string;
|
|
@@ -35,5 +48,17 @@ interface Auth {
|
|
|
35
48
|
}
|
|
36
49
|
interface SignedIn extends Auth {
|
|
37
50
|
machine: Machine;
|
|
51
|
+
organization: string;
|
|
52
|
+
}
|
|
53
|
+
export interface Seat {
|
|
54
|
+
home: string;
|
|
55
|
+
config: Config;
|
|
38
56
|
}
|
|
57
|
+
type Config = z.infer<typeof ConfigSchema>;
|
|
58
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
59
|
+
url: z.ZodString;
|
|
60
|
+
machineId: z.ZodString;
|
|
61
|
+
organization: z.ZodOptional<z.ZodString>;
|
|
62
|
+
account: z.ZodOptional<z.ZodString>;
|
|
63
|
+
}, z.core.$strip>;
|
|
39
64
|
export {};
|
package/dist/machine.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
@@ -15,9 +15,24 @@ import { ui } from './ui.js';
|
|
|
15
15
|
import { isNewer, update } from './update.js';
|
|
16
16
|
const run = promisify(execFile);
|
|
17
17
|
export const machine = {
|
|
18
|
-
|
|
18
|
+
root() {
|
|
19
19
|
return process.env.CRAFTSPACE_CLI_HOME ?? path.join(os.homedir(), '.craftspace');
|
|
20
20
|
},
|
|
21
|
+
async seats() {
|
|
22
|
+
await settleLayout();
|
|
23
|
+
const entries = await readdir(machine.root(), { withFileTypes: true }).catch(() => []);
|
|
24
|
+
const seats = [];
|
|
25
|
+
for (const entry of entries.filter((found) => found.isDirectory())) {
|
|
26
|
+
const home = path.join(machine.root(), entry.name);
|
|
27
|
+
const config = await readConfig(home);
|
|
28
|
+
if (config !== null)
|
|
29
|
+
seats.push({ home, config });
|
|
30
|
+
}
|
|
31
|
+
const legacy = await readConfig(machine.root());
|
|
32
|
+
if (legacy !== null)
|
|
33
|
+
seats.push({ home: machine.root(), config: legacy });
|
|
34
|
+
return seats;
|
|
35
|
+
},
|
|
21
36
|
async login({ token, url, install }) {
|
|
22
37
|
const answer = await call({
|
|
23
38
|
url,
|
|
@@ -35,26 +50,29 @@ export const machine = {
|
|
|
35
50
|
},
|
|
36
51
|
schema: MachineLoginResponseSchema,
|
|
37
52
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
await
|
|
53
|
+
const organization = toSpaceSlug(answer.organizationName) || 'team';
|
|
54
|
+
const account = answer.machine.ownerAccountId;
|
|
55
|
+
await refuseSecondAccount({ account, organization });
|
|
56
|
+
const home = path.join(machine.root(), organization);
|
|
57
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
58
|
+
await writeConfig(home, { url, machineId: answer.machine.id, organization, account });
|
|
59
|
+
await writeFile(tokenPath(home), token, { mode: 0o600 });
|
|
41
60
|
await writeAgentConfigs({ writes: answer.write });
|
|
42
|
-
await wireRepos({ url, token, dirs: clonedDirs(await readEnvironment()) });
|
|
61
|
+
await wireRepos({ url, token, organization, dirs: clonedDirs(await readEnvironment(home)) });
|
|
43
62
|
await installService();
|
|
44
63
|
if (install)
|
|
45
|
-
await machine.beatOnce(ui.say);
|
|
46
|
-
return answer.machine;
|
|
64
|
+
await machine.beatOnce({ home, say: ui.say });
|
|
65
|
+
return { url, token, machine: answer.machine, organization };
|
|
47
66
|
},
|
|
48
|
-
async workDir() {
|
|
49
|
-
|
|
50
|
-
return organization === undefined ? null : setup.workDir(organization);
|
|
67
|
+
async workDir(organization) {
|
|
68
|
+
return setup.workDir(organization);
|
|
51
69
|
},
|
|
52
70
|
async controlPlane(override) {
|
|
53
|
-
const where = override ?? process.env.CRAFTSPACE_URL ?? (await
|
|
71
|
+
const where = override ?? process.env.CRAFTSPACE_URL ?? (await anySeat())?.config.url ?? DEFAULT_URL;
|
|
54
72
|
return where.replace(/\/$/, '');
|
|
55
73
|
},
|
|
56
74
|
async signIn({ url, why, install = false }) {
|
|
57
|
-
const where = url ?? (await
|
|
75
|
+
const where = url ?? (await anySeat())?.config.url ?? DEFAULT_URL;
|
|
58
76
|
if (!process.stdin.isTTY)
|
|
59
77
|
throw new Error(`${why} Run: cs login --token <token>`);
|
|
60
78
|
const page = `${where}/workstations`;
|
|
@@ -73,16 +91,34 @@ export const machine = {
|
|
|
73
91
|
if (token === undefined)
|
|
74
92
|
throw new Error('That line carries no key.');
|
|
75
93
|
const signed = await machine.login({ token, url: where, install });
|
|
76
|
-
ui.say(`\n${ui.ok(`Signed in as ${signed.ownerName}. This machine is ${ui.name(signed.name)}.`)}`);
|
|
77
|
-
return
|
|
94
|
+
ui.say(`\n${ui.ok(`Signed in as ${signed.machine.ownerName}. This machine is ${ui.name(signed.machine.name)}.`)}`);
|
|
95
|
+
return signed;
|
|
78
96
|
},
|
|
79
97
|
async status() {
|
|
80
|
-
const
|
|
81
|
-
|
|
98
|
+
const seats = await machine.seats();
|
|
99
|
+
if (seats.length === 0) {
|
|
100
|
+
const signed = await machine.signIn({ why: 'This machine is not signed in to Craftspace yet.' });
|
|
101
|
+
return [{ machine: signed.machine, url: signed.url, organization: signed.organization }];
|
|
102
|
+
}
|
|
103
|
+
const found = [];
|
|
104
|
+
for (const seat of seats) {
|
|
105
|
+
const live = await withAuth(await authOf(seat), (auth) => call({ ...auth, method: 'GET', path: '/api/machines/me', schema: MachineSchema }));
|
|
106
|
+
found.push({ machine: live.value, url: live.auth.url, organization: seat.config.organization ?? '?' });
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
82
109
|
},
|
|
83
|
-
async
|
|
84
|
-
const
|
|
85
|
-
|
|
110
|
+
async beatAll(say) {
|
|
111
|
+
const seats = await machine.seats();
|
|
112
|
+
if (seats.length === 0)
|
|
113
|
+
throw new Error('This machine is not signed in. Run: cs login --token <token>');
|
|
114
|
+
let busy = false;
|
|
115
|
+
for (const seat of seats)
|
|
116
|
+
busy = (await machine.beatOnce({ home: seat.home, say })) || busy;
|
|
117
|
+
return busy;
|
|
118
|
+
},
|
|
119
|
+
async beatOnce({ home, say }) {
|
|
120
|
+
const { url, token, organization } = await requireSignedIn(home);
|
|
121
|
+
const built = await readEnvironment(home);
|
|
86
122
|
const answer = await call({
|
|
87
123
|
url,
|
|
88
124
|
token,
|
|
@@ -92,8 +128,8 @@ export const machine = {
|
|
|
92
128
|
cli: CLI_VERSION,
|
|
93
129
|
...load(),
|
|
94
130
|
sessions: await readSessions(),
|
|
95
|
-
runs: drainReports(),
|
|
96
|
-
tools: await checks(),
|
|
131
|
+
runs: drainReports(home),
|
|
132
|
+
tools: await checks(home),
|
|
97
133
|
environment: built?.results ?? [],
|
|
98
134
|
templateId: built?.templateId ?? null,
|
|
99
135
|
...(organization === undefined ? {} : { workDir: setup.workDir(organization) }),
|
|
@@ -106,10 +142,10 @@ export const machine = {
|
|
|
106
142
|
updateWanted = answer.cli.version;
|
|
107
143
|
const workDir = setup.workDir(answer.organization);
|
|
108
144
|
if (answer.organization !== organization)
|
|
109
|
-
await writeConfig({ organization: answer.organization });
|
|
110
|
-
await migrate.toLatest({ home: machine.
|
|
111
|
-
await writeAuthorizedKeys(answer.authorizedKeys);
|
|
112
|
-
const building = buildEnvironment({ next: answer.environment, workDir, say });
|
|
145
|
+
await writeConfig(home, { organization: answer.organization });
|
|
146
|
+
await migrate.toLatest({ home: machine.root(), workDir }).catch(() => []);
|
|
147
|
+
await writeAuthorizedKeys({ organization: answer.organization, keys: answer.authorizedKeys });
|
|
148
|
+
const building = buildEnvironment({ next: answer.environment, home, workDir, say });
|
|
113
149
|
if (say === undefined)
|
|
114
150
|
void building.catch(() => undefined);
|
|
115
151
|
else
|
|
@@ -119,9 +155,9 @@ export const machine = {
|
|
|
119
155
|
continue;
|
|
120
156
|
running.add(work.id);
|
|
121
157
|
void runner
|
|
122
|
-
.unattended({ work, cwd: startIn(built), onProgress: report })
|
|
123
|
-
.then(report)
|
|
124
|
-
.catch((error) => report({ id: work.id, state: 'failed', output: messageOf(error) }))
|
|
158
|
+
.unattended({ work, cwd: startIn(built), onProgress: (next) => report(home, next) })
|
|
159
|
+
.then((next) => report(home, next))
|
|
160
|
+
.catch((error) => report(home, { id: work.id, state: 'failed', output: messageOf(error) }))
|
|
125
161
|
.finally(() => running.delete(work.id));
|
|
126
162
|
}
|
|
127
163
|
return running.size > 0 || answer.work.length > 0;
|
|
@@ -142,7 +178,7 @@ export const machine = {
|
|
|
142
178
|
}
|
|
143
179
|
}
|
|
144
180
|
try {
|
|
145
|
-
busy = await machine.
|
|
181
|
+
busy = await machine.beatAll();
|
|
146
182
|
failures = 0;
|
|
147
183
|
}
|
|
148
184
|
catch (error) {
|
|
@@ -153,7 +189,7 @@ export const machine = {
|
|
|
153
189
|
}
|
|
154
190
|
},
|
|
155
191
|
async run() {
|
|
156
|
-
const first = await
|
|
192
|
+
const first = await authOfAnySeatOrAsk();
|
|
157
193
|
const { auth, value: answer } = await withAuth(first, (live) => call({ ...live, method: 'GET', path: '/api/machines', schema: MachinesResponseSchema }));
|
|
158
194
|
if (answer.machines.length === 0)
|
|
159
195
|
return nowhereToGo(auth);
|
|
@@ -171,23 +207,58 @@ export const machine = {
|
|
|
171
207
|
return { text: BEATING, ok: true };
|
|
172
208
|
},
|
|
173
209
|
async logout() {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
210
|
+
for (const seat of await machine.seats()) {
|
|
211
|
+
const token = await readToken(seat.home);
|
|
212
|
+
if (token === null)
|
|
213
|
+
continue;
|
|
214
|
+
await call({
|
|
215
|
+
url: seat.config.url,
|
|
216
|
+
token,
|
|
217
|
+
method: 'POST',
|
|
218
|
+
path: '/api/machines/logout',
|
|
219
|
+
schema: z.object({}),
|
|
220
|
+
}).catch(() => undefined);
|
|
221
|
+
await removeAgentConfigs(seat.home);
|
|
222
|
+
if (seat.config.organization !== undefined) {
|
|
223
|
+
await writeAuthorizedKeys({ organization: seat.config.organization, keys: [] });
|
|
224
|
+
}
|
|
178
225
|
}
|
|
179
|
-
await
|
|
226
|
+
await unwireRepos();
|
|
180
227
|
await uninstallService();
|
|
181
|
-
await rm(machine.
|
|
228
|
+
await rm(machine.root(), { recursive: true, force: true });
|
|
182
229
|
},
|
|
183
230
|
};
|
|
184
|
-
async function requireSignedIn() {
|
|
185
|
-
const config = await readConfig();
|
|
186
|
-
const token = await readToken();
|
|
231
|
+
async function requireSignedIn(home) {
|
|
232
|
+
const config = await readConfig(home);
|
|
233
|
+
const token = await readToken(home);
|
|
187
234
|
if (!config || !token)
|
|
188
235
|
throw new Error('This machine is not signed in. Run: cs login --token <token>');
|
|
189
236
|
return { url: config.url, token, ...spreadIfDefined({ organization: config.organization }) };
|
|
190
237
|
}
|
|
238
|
+
// A box holds one ACCOUNT and as many of that account's orgs as it likes. Two accounts would share this
|
|
239
|
+
// box's one ssh key, its sessions and its ~/.claude.json, so each would be acting as the other.
|
|
240
|
+
async function refuseSecondAccount({ account, organization, }) {
|
|
241
|
+
const taken = (await machine.seats()).find((seat) => seat.config.account !== undefined && seat.config.account !== account);
|
|
242
|
+
if (taken === undefined)
|
|
243
|
+
return;
|
|
244
|
+
throw new Error(`This box is already signed in as someone else, under ${taken.config.organization ?? 'another org'}. ` +
|
|
245
|
+
`One box holds one account, any number of its orgs. Run: cs logout, then sign in for ${organization}.`);
|
|
246
|
+
}
|
|
247
|
+
async function anySeat() {
|
|
248
|
+
return (await machine.seats())[0];
|
|
249
|
+
}
|
|
250
|
+
async function authOf(seat) {
|
|
251
|
+
const token = await readToken(seat.home);
|
|
252
|
+
if (token === null)
|
|
253
|
+
throw new Error('This machine is not signed in. Run: cs login --token <token>');
|
|
254
|
+
return { url: seat.config.url, token };
|
|
255
|
+
}
|
|
256
|
+
async function authOfAnySeatOrAsk() {
|
|
257
|
+
const seat = await anySeat();
|
|
258
|
+
if (seat === undefined)
|
|
259
|
+
return machine.signIn({ why: 'This machine is not signed in to Craftspace yet.' });
|
|
260
|
+
return authOf(seat);
|
|
261
|
+
}
|
|
191
262
|
async function pick(machines) {
|
|
192
263
|
return select({
|
|
193
264
|
message: 'Which workstation?',
|
|
@@ -242,27 +313,29 @@ function nowhereToGo({ url }) {
|
|
|
242
313
|
ui.say('\n');
|
|
243
314
|
return 1;
|
|
244
315
|
}
|
|
245
|
-
async function checks() {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
316
|
+
async function checks(home) {
|
|
317
|
+
const seen = probed.get(home);
|
|
318
|
+
if (seen !== undefined && Date.now() - seen.at < PROBE_EVERY_MS)
|
|
319
|
+
return seen.tools;
|
|
320
|
+
const organization = (await readConfig(home))?.organization ?? 'team';
|
|
321
|
+
const dirs = clonedDirs(await readEnvironment(home));
|
|
322
|
+
const first = await probe.tools({ dirs, organization });
|
|
250
323
|
const unwired = first.some((tool) => tool.id === 'brain' && !tool.ok);
|
|
251
324
|
const found = unwired && installs() && dirs.length > 0
|
|
252
|
-
? await rewire(dirs).then(() => probe.tools({ dirs }), () => first)
|
|
325
|
+
? await rewire(home, dirs).then(() => probe.tools({ dirs, organization }), () => first)
|
|
253
326
|
: first;
|
|
254
|
-
probed
|
|
327
|
+
probed.set(home, { at: Date.now(), tools: found });
|
|
255
328
|
return found;
|
|
256
329
|
}
|
|
257
|
-
async function buildEnvironment({ next, workDir, say, }) {
|
|
258
|
-
if (building || !installs() || next.templateId === null)
|
|
330
|
+
async function buildEnvironment({ next, home, workDir, say, }) {
|
|
331
|
+
if (building.has(home) || !installs() || next.templateId === null)
|
|
259
332
|
return;
|
|
260
|
-
const built = await readEnvironment();
|
|
333
|
+
const built = await readEnvironment(home);
|
|
261
334
|
const buildsSteps = buildIsDue(built, next.templateId);
|
|
262
335
|
const clonesRepos = cloneIsDue({ built, wanted: next.repos, workDir });
|
|
263
336
|
if (!buildsSteps && !clonesRepos)
|
|
264
337
|
return;
|
|
265
|
-
building
|
|
338
|
+
building.add(home);
|
|
266
339
|
try {
|
|
267
340
|
let results = built?.results ?? [];
|
|
268
341
|
let repos = built?.repos ?? [];
|
|
@@ -273,12 +346,12 @@ async function buildEnvironment({ next, workDir, say, }) {
|
|
|
273
346
|
if (clonesRepos) {
|
|
274
347
|
say?.(ui.heading(`Cloning the ${next.repos.length} repos your team works in, into ${workDir}`));
|
|
275
348
|
repos = await setup.cloneRepos({ repos: next.repos, workDir, say });
|
|
276
|
-
await rewire(repos.filter((repo) => repo.ok).map((repo) => repo.path));
|
|
349
|
+
await rewire(home, repos.filter((repo) => repo.ok).map((repo) => repo.path));
|
|
277
350
|
}
|
|
278
|
-
await writeEnvironment({ templateId: next.templateId, results, repos, at: Date.now() });
|
|
351
|
+
await writeEnvironment(home, { templateId: next.templateId, results, repos, at: Date.now() });
|
|
279
352
|
}
|
|
280
353
|
finally {
|
|
281
|
-
building
|
|
354
|
+
building.delete(home);
|
|
282
355
|
}
|
|
283
356
|
}
|
|
284
357
|
function cloneIsDue({ built, wanted, workDir, }) {
|
|
@@ -305,35 +378,42 @@ function buildIsDue(built, templateId) {
|
|
|
305
378
|
return false;
|
|
306
379
|
return Date.now() - built.at > REBUILD_AFTER_MS;
|
|
307
380
|
}
|
|
308
|
-
async function readEnvironment() {
|
|
309
|
-
const raw = await readFile(environmentPath(), 'utf8').catch(() => null);
|
|
381
|
+
async function readEnvironment(home) {
|
|
382
|
+
const raw = await readFile(environmentPath(home), 'utf8').catch(() => null);
|
|
310
383
|
if (raw === null)
|
|
311
384
|
return null;
|
|
312
385
|
const parsed = BuiltSchema.safeParse(JSON.parse(raw));
|
|
313
386
|
return parsed.success ? parsed.data : null;
|
|
314
387
|
}
|
|
315
|
-
async function writeEnvironment(built) {
|
|
316
|
-
await mkdir(
|
|
317
|
-
await writeFile(environmentPath(), JSON.stringify(built, null, 2), { mode: 0o600 });
|
|
388
|
+
async function writeEnvironment(home, built) {
|
|
389
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
390
|
+
await writeFile(environmentPath(home), JSON.stringify(built, null, 2), { mode: 0o600 });
|
|
318
391
|
}
|
|
319
392
|
function installs() {
|
|
320
393
|
return process.env.CRAFTSPACE_NO_INSTALL !== '1';
|
|
321
394
|
}
|
|
322
|
-
async function rewire(dirs) {
|
|
323
|
-
const { url, token } = await requireSignedIn();
|
|
324
|
-
await wireRepos({ url, token, dirs });
|
|
395
|
+
async function rewire(home, dirs) {
|
|
396
|
+
const { url, token, organization } = await requireSignedIn(home);
|
|
397
|
+
await wireRepos({ url, token, organization: organization ?? 'team', dirs });
|
|
325
398
|
}
|
|
326
399
|
function clonedDirs(built) {
|
|
327
400
|
return (built?.repos ?? []).filter((repo) => repo.ok).map((repo) => repo.path);
|
|
328
401
|
}
|
|
329
|
-
function report(next) {
|
|
330
|
-
reports.
|
|
402
|
+
function report(home, next) {
|
|
403
|
+
const mine = reports.get(home) ?? new Map();
|
|
404
|
+
mine.set(next.id, next);
|
|
405
|
+
reports.set(home, mine);
|
|
331
406
|
}
|
|
332
|
-
|
|
333
|
-
|
|
407
|
+
// A run belongs to the org that queued it, so its reports go back on THAT seat's beat. Draining one shared
|
|
408
|
+
// map would post org A's output to org B, which answers 404 and loses the run.
|
|
409
|
+
function drainReports(home) {
|
|
410
|
+
const mine = reports.get(home);
|
|
411
|
+
if (mine === undefined)
|
|
412
|
+
return [];
|
|
413
|
+
const pending = [...mine.values()];
|
|
334
414
|
for (const item of pending)
|
|
335
415
|
if (item.state !== 'running')
|
|
336
|
-
|
|
416
|
+
mine.delete(item.id);
|
|
337
417
|
return pending;
|
|
338
418
|
}
|
|
339
419
|
async function call({ url, token, method, path: route, body, schema, }) {
|
|
@@ -357,13 +437,6 @@ class Unauthorized extends Error {
|
|
|
357
437
|
super('This machine no longer has a key Craftspace accepts.');
|
|
358
438
|
}
|
|
359
439
|
}
|
|
360
|
-
async function signedInOrAsk() {
|
|
361
|
-
const config = await readConfig();
|
|
362
|
-
const token = await readToken();
|
|
363
|
-
if (config && token)
|
|
364
|
-
return { url: config.url, token };
|
|
365
|
-
return machine.signIn({ why: 'This machine is not signed in to Craftspace yet.' });
|
|
366
|
-
}
|
|
367
440
|
async function withAuth(auth, work) {
|
|
368
441
|
try {
|
|
369
442
|
return { auth, value: await work(auth) };
|
|
@@ -387,32 +460,47 @@ export function tokenIn(line) {
|
|
|
387
460
|
return /cst_[A-Za-z0-9_-]+/.exec(line)?.[0];
|
|
388
461
|
}
|
|
389
462
|
async function ensureKey() {
|
|
390
|
-
const priv = path.join(machine.
|
|
463
|
+
const priv = path.join(machine.root(), 'id_ed25519');
|
|
391
464
|
const pub = `${priv}.pub`;
|
|
392
465
|
const existing = await readFile(pub, 'utf8').catch(() => null);
|
|
393
466
|
if (existing !== null)
|
|
394
467
|
return existing.trim();
|
|
395
|
-
await mkdir(machine.
|
|
468
|
+
await mkdir(machine.root(), { recursive: true, mode: 0o700 });
|
|
396
469
|
await run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-q', '-f', priv, '-C', `craftspace-${os.hostname()}`]);
|
|
397
470
|
return (await readFile(pub, 'utf8')).trim();
|
|
398
471
|
}
|
|
399
|
-
async function writeConfig(next) {
|
|
400
|
-
const merged = { ...(await readConfig()), ...next };
|
|
401
|
-
await mkdir(
|
|
402
|
-
await writeFile(configPath(), `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
472
|
+
async function writeConfig(home, next) {
|
|
473
|
+
const merged = { ...(await readConfig(home)), ...next };
|
|
474
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
475
|
+
await writeFile(configPath(home), `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
403
476
|
}
|
|
404
|
-
async function readConfig() {
|
|
405
|
-
const raw = await readFile(configPath(), 'utf8').catch(() => null);
|
|
477
|
+
async function readConfig(home) {
|
|
478
|
+
const raw = await readFile(configPath(home), 'utf8').catch(() => null);
|
|
406
479
|
if (raw === null)
|
|
407
480
|
return null;
|
|
408
|
-
|
|
481
|
+
const parsed = ConfigSchema.safeParse(JSON.parse(raw));
|
|
482
|
+
return parsed.success ? parsed.data : null;
|
|
409
483
|
}
|
|
410
|
-
async function readToken() {
|
|
411
|
-
const raw = await readFile(tokenPath(), 'utf8').catch(() => null);
|
|
484
|
+
async function readToken(home) {
|
|
485
|
+
const raw = await readFile(tokenPath(home), 'utf8').catch(() => null);
|
|
412
486
|
return raw === null ? null : raw.trim();
|
|
413
487
|
}
|
|
488
|
+
// One box, one account, one seat per org. Everything an org owns lives in its own directory under the
|
|
489
|
+
// root; the box's ssh key, its sessions and the migration marks are the box's, so they stay at the root.
|
|
490
|
+
async function settleLayout() {
|
|
491
|
+
const root = machine.root();
|
|
492
|
+
const config = await readConfig(root);
|
|
493
|
+
const organization = config?.organization;
|
|
494
|
+
if (config === null || organization === undefined)
|
|
495
|
+
return;
|
|
496
|
+
const home = path.join(root, organization);
|
|
497
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
498
|
+
for (const name of SEAT_FILES) {
|
|
499
|
+
await rename(path.join(root, name), path.join(home, name)).catch(() => undefined);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
414
502
|
async function readSessions() {
|
|
415
|
-
const dir = path.join(machine.
|
|
503
|
+
const dir = path.join(machine.root(), 'sessions');
|
|
416
504
|
const names = await readdir(dir).catch(() => []);
|
|
417
505
|
const sessions = [];
|
|
418
506
|
for (const name of names.filter((entry) => entry.endsWith('.json'))) {
|
|
@@ -425,22 +513,36 @@ async function readSessions() {
|
|
|
425
513
|
}
|
|
426
514
|
return sessions.slice(0, MAX_REPORTED_SESSIONS);
|
|
427
515
|
}
|
|
428
|
-
|
|
516
|
+
// Each org grants its own keys, so each owns its own marked block. One shared pair of markers made the
|
|
517
|
+
// second org's write delete the first org's keys, which locks out whoever was relying on them.
|
|
518
|
+
async function writeAuthorizedKeys({ organization, keys }) {
|
|
429
519
|
const target = path.join(os.homedir(), '.ssh', 'authorized_keys');
|
|
430
520
|
const current = await readFile(target, 'utf8').catch(() => '');
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
521
|
+
const block = keys.length === 0 ? '' : `${keys.join('\n')}\n`;
|
|
522
|
+
const next = replaceBlock({
|
|
523
|
+
text: replaceBlock({ text: current, begin: KEYS_BEGIN, end: KEYS_END, block: '' }),
|
|
524
|
+
begin: `${KEYS_BEGIN} ${organization}`,
|
|
525
|
+
end: `${KEYS_END} ${organization}`,
|
|
526
|
+
block,
|
|
527
|
+
});
|
|
435
528
|
if (next === current)
|
|
436
529
|
return;
|
|
437
530
|
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
438
531
|
await writeFile(target, next, { mode: 0o600 });
|
|
439
532
|
}
|
|
533
|
+
export function replaceBlock({ text, begin, end, block, }) {
|
|
534
|
+
const before = text.split(begin)[0] ?? '';
|
|
535
|
+
const after = text.includes(end) ? (text.split(end)[1] ?? '') : text.includes(begin) ? '' : null;
|
|
536
|
+
if (after === null && block === '')
|
|
537
|
+
return text;
|
|
538
|
+
const kept = after ?? '';
|
|
539
|
+
const wrapped = block === '' ? '' : `${begin}\n${block}${end}\n`;
|
|
540
|
+
return `${trimEnd(before)}${trimEnd(before) === '' ? '' : '\n'}${wrapped}${kept.replace(/^\n/, '')}`;
|
|
541
|
+
}
|
|
440
542
|
function trimEnd(text) {
|
|
441
543
|
return text.replace(/\s+$/, '');
|
|
442
544
|
}
|
|
443
|
-
async function wireRepos({ url, token, dirs }) {
|
|
545
|
+
async function wireRepos({ url, token, organization, dirs, }) {
|
|
444
546
|
if (dirs.length === 0)
|
|
445
547
|
return;
|
|
446
548
|
await migrate.stripGlobalMcp();
|
|
@@ -449,17 +551,27 @@ async function wireRepos({ url, token, dirs }) {
|
|
|
449
551
|
for (const dir of dirs) {
|
|
450
552
|
const here = isPlainObject(projects[dir]) ? { ...projects[dir] } : {};
|
|
451
553
|
const servers = isPlainObject(here.mcpServers) ? { ...here.mcpServers } : {};
|
|
452
|
-
|
|
554
|
+
delete servers.craftspace;
|
|
555
|
+
projects[dir] = {
|
|
556
|
+
...here,
|
|
557
|
+
hasTrustDialogAccepted: true,
|
|
558
|
+
mcpServers: { ...servers, [serverNameFor(organization)]: server },
|
|
559
|
+
};
|
|
453
560
|
}
|
|
454
561
|
});
|
|
455
562
|
}
|
|
563
|
+
// A repo is only ever cloned under ONE org's work dir, so the entry could be named `craftspace` and never
|
|
564
|
+
// collide. It carries the org anyway: `cs logout` has to know whose entry it is removing, and a member
|
|
565
|
+
// reading ~/.claude.json should be able to tell which brain a checkout is pointed at.
|
|
566
|
+
function serverNameFor(organization) {
|
|
567
|
+
return `craftspace-${organization}`;
|
|
568
|
+
}
|
|
456
569
|
async function unwireRepos() {
|
|
457
570
|
await editClaudeConfig((projects) => {
|
|
458
571
|
for (const [dir, found] of Object.entries(projects)) {
|
|
459
572
|
if (!isPlainObject(found) || !isPlainObject(found.mcpServers))
|
|
460
573
|
continue;
|
|
461
|
-
const servers =
|
|
462
|
-
delete servers.craftspace;
|
|
574
|
+
const servers = Object.fromEntries(Object.entries(found.mcpServers).filter(([name]) => name !== 'craftspace' && !name.startsWith('craftspace-')));
|
|
463
575
|
projects[dir] = { ...found, mcpServers: servers };
|
|
464
576
|
}
|
|
465
577
|
});
|
|
@@ -478,7 +590,7 @@ async function editClaudeConfig(edit) {
|
|
|
478
590
|
async function writeAgentConfigs({ writes, }) {
|
|
479
591
|
if (writes.length === 0)
|
|
480
592
|
return;
|
|
481
|
-
const owned = [
|
|
593
|
+
const owned = [];
|
|
482
594
|
for (const write of writes) {
|
|
483
595
|
const target = expand(write.path);
|
|
484
596
|
await mkdir(path.dirname(target), { recursive: true });
|
|
@@ -489,10 +601,10 @@ async function writeAgentConfigs({ writes, }) {
|
|
|
489
601
|
await writeFile(target, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
490
602
|
owned.push(target);
|
|
491
603
|
}
|
|
492
|
-
await writeFile(ownedPath(), JSON.stringify({ paths: [...new Set(owned)] }, null, 2));
|
|
604
|
+
await writeFile(ownedPath(machine.root()), JSON.stringify({ paths: [...new Set(owned)] }, null, 2));
|
|
493
605
|
}
|
|
494
|
-
async function readOwned() {
|
|
495
|
-
const raw = await readFile(ownedPath(), 'utf8').catch(() => null);
|
|
606
|
+
async function readOwned(home) {
|
|
607
|
+
const raw = await readFile(ownedPath(home), 'utf8').catch(() => null);
|
|
496
608
|
if (raw === null)
|
|
497
609
|
return [];
|
|
498
610
|
const parsed = OwnedSchema.safeParse(JSON.parse(raw));
|
|
@@ -510,9 +622,8 @@ function mergeInto(current, incoming) {
|
|
|
510
622
|
function isPlainObject(value) {
|
|
511
623
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
512
624
|
}
|
|
513
|
-
async function removeAgentConfigs() {
|
|
514
|
-
await
|
|
515
|
-
for (const target of await readOwned()) {
|
|
625
|
+
async function removeAgentConfigs(home) {
|
|
626
|
+
for (const target of await readOwned(home)) {
|
|
516
627
|
const current = await readFile(target, 'utf8').catch(() => null);
|
|
517
628
|
if (current === null)
|
|
518
629
|
continue;
|
|
@@ -609,17 +720,17 @@ function entryPath() {
|
|
|
609
720
|
function expand(target) {
|
|
610
721
|
return target.startsWith('~/') ? path.join(os.homedir(), target.slice(2)) : target;
|
|
611
722
|
}
|
|
612
|
-
function configPath() {
|
|
613
|
-
return path.join(
|
|
723
|
+
function configPath(home) {
|
|
724
|
+
return path.join(home, 'config.json');
|
|
614
725
|
}
|
|
615
|
-
function tokenPath() {
|
|
616
|
-
return path.join(
|
|
726
|
+
function tokenPath(home) {
|
|
727
|
+
return path.join(home, 'token');
|
|
617
728
|
}
|
|
618
|
-
function ownedPath() {
|
|
619
|
-
return path.join(
|
|
729
|
+
function ownedPath(home) {
|
|
730
|
+
return path.join(home, 'owned.json');
|
|
620
731
|
}
|
|
621
|
-
function environmentPath() {
|
|
622
|
-
return path.join(
|
|
732
|
+
function environmentPath(home) {
|
|
733
|
+
return path.join(home, 'environment.json');
|
|
623
734
|
}
|
|
624
735
|
function load() {
|
|
625
736
|
const cores = Math.max(1, os.cpus().length);
|
|
@@ -638,7 +749,12 @@ export function nextDelayMs(failures, busy = false) {
|
|
|
638
749
|
function messageOf(error) {
|
|
639
750
|
return error instanceof Error ? error.message : String(error);
|
|
640
751
|
}
|
|
641
|
-
const ConfigSchema = z.object({
|
|
752
|
+
const ConfigSchema = z.object({
|
|
753
|
+
url: z.string(),
|
|
754
|
+
machineId: z.string(),
|
|
755
|
+
organization: z.string().optional(),
|
|
756
|
+
account: z.string().optional(),
|
|
757
|
+
});
|
|
642
758
|
const BuiltSchema = z.object({
|
|
643
759
|
templateId: z.string(),
|
|
644
760
|
results: z.array(WorkstationStepResultSchema),
|
|
@@ -647,6 +763,7 @@ const BuiltSchema = z.object({
|
|
|
647
763
|
});
|
|
648
764
|
const OwnedSchema = z.object({ paths: z.array(z.string()) });
|
|
649
765
|
const CLAUDE_FILE = '.claude.json';
|
|
766
|
+
const SEAT_FILES = ['config.json', 'token', 'environment.json', 'owned.json'];
|
|
650
767
|
const SERVICE_NAME = 'craftspace';
|
|
651
768
|
const LAUNCH_LABEL = 'app.craftspace.machine';
|
|
652
769
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -677,7 +794,7 @@ const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
|
|
|
677
794
|
const PROBE_EVERY_MS = 300_000;
|
|
678
795
|
const REBUILD_AFTER_MS = 21_600_000;
|
|
679
796
|
let updateWanted = null;
|
|
680
|
-
|
|
681
|
-
|
|
797
|
+
const probed = new Map();
|
|
798
|
+
const building = new Set();
|
|
682
799
|
const reports = new Map();
|
|
683
800
|
const running = new Set();
|