@craftspace/cli 0.9.0 → 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 +265 -148
- package/dist/machine.d.ts +30 -5
- package/dist/machine.js +231 -113
- package/dist/migrate.d.ts +1 -0
- package/dist/migrate.js +18 -15
- 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,40 +513,65 @@ 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;
|
|
548
|
+
await migrate.stripGlobalMcp();
|
|
446
549
|
const server = { type: 'http', url: `${url}/mcp`, headers: { Authorization: `Bearer ${token}` } };
|
|
447
550
|
await editClaudeConfig((projects) => {
|
|
448
551
|
for (const dir of dirs) {
|
|
449
552
|
const here = isPlainObject(projects[dir]) ? { ...projects[dir] } : {};
|
|
450
553
|
const servers = isPlainObject(here.mcpServers) ? { ...here.mcpServers } : {};
|
|
451
|
-
|
|
554
|
+
delete servers.craftspace;
|
|
555
|
+
projects[dir] = {
|
|
556
|
+
...here,
|
|
557
|
+
hasTrustDialogAccepted: true,
|
|
558
|
+
mcpServers: { ...servers, [serverNameFor(organization)]: server },
|
|
559
|
+
};
|
|
452
560
|
}
|
|
453
561
|
});
|
|
454
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
|
+
}
|
|
455
569
|
async function unwireRepos() {
|
|
456
570
|
await editClaudeConfig((projects) => {
|
|
457
571
|
for (const [dir, found] of Object.entries(projects)) {
|
|
458
572
|
if (!isPlainObject(found) || !isPlainObject(found.mcpServers))
|
|
459
573
|
continue;
|
|
460
|
-
const servers =
|
|
461
|
-
delete servers.craftspace;
|
|
574
|
+
const servers = Object.fromEntries(Object.entries(found.mcpServers).filter(([name]) => name !== 'craftspace' && !name.startsWith('craftspace-')));
|
|
462
575
|
projects[dir] = { ...found, mcpServers: servers };
|
|
463
576
|
}
|
|
464
577
|
});
|
|
@@ -477,7 +590,7 @@ async function editClaudeConfig(edit) {
|
|
|
477
590
|
async function writeAgentConfigs({ writes, }) {
|
|
478
591
|
if (writes.length === 0)
|
|
479
592
|
return;
|
|
480
|
-
const owned = [
|
|
593
|
+
const owned = [];
|
|
481
594
|
for (const write of writes) {
|
|
482
595
|
const target = expand(write.path);
|
|
483
596
|
await mkdir(path.dirname(target), { recursive: true });
|
|
@@ -488,10 +601,10 @@ async function writeAgentConfigs({ writes, }) {
|
|
|
488
601
|
await writeFile(target, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
489
602
|
owned.push(target);
|
|
490
603
|
}
|
|
491
|
-
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));
|
|
492
605
|
}
|
|
493
|
-
async function readOwned() {
|
|
494
|
-
const raw = await readFile(ownedPath(), 'utf8').catch(() => null);
|
|
606
|
+
async function readOwned(home) {
|
|
607
|
+
const raw = await readFile(ownedPath(home), 'utf8').catch(() => null);
|
|
495
608
|
if (raw === null)
|
|
496
609
|
return [];
|
|
497
610
|
const parsed = OwnedSchema.safeParse(JSON.parse(raw));
|
|
@@ -509,9 +622,8 @@ function mergeInto(current, incoming) {
|
|
|
509
622
|
function isPlainObject(value) {
|
|
510
623
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
511
624
|
}
|
|
512
|
-
async function removeAgentConfigs() {
|
|
513
|
-
await
|
|
514
|
-
for (const target of await readOwned()) {
|
|
625
|
+
async function removeAgentConfigs(home) {
|
|
626
|
+
for (const target of await readOwned(home)) {
|
|
515
627
|
const current = await readFile(target, 'utf8').catch(() => null);
|
|
516
628
|
if (current === null)
|
|
517
629
|
continue;
|
|
@@ -608,17 +720,17 @@ function entryPath() {
|
|
|
608
720
|
function expand(target) {
|
|
609
721
|
return target.startsWith('~/') ? path.join(os.homedir(), target.slice(2)) : target;
|
|
610
722
|
}
|
|
611
|
-
function configPath() {
|
|
612
|
-
return path.join(
|
|
723
|
+
function configPath(home) {
|
|
724
|
+
return path.join(home, 'config.json');
|
|
613
725
|
}
|
|
614
|
-
function tokenPath() {
|
|
615
|
-
return path.join(
|
|
726
|
+
function tokenPath(home) {
|
|
727
|
+
return path.join(home, 'token');
|
|
616
728
|
}
|
|
617
|
-
function ownedPath() {
|
|
618
|
-
return path.join(
|
|
729
|
+
function ownedPath(home) {
|
|
730
|
+
return path.join(home, 'owned.json');
|
|
619
731
|
}
|
|
620
|
-
function environmentPath() {
|
|
621
|
-
return path.join(
|
|
732
|
+
function environmentPath(home) {
|
|
733
|
+
return path.join(home, 'environment.json');
|
|
622
734
|
}
|
|
623
735
|
function load() {
|
|
624
736
|
const cores = Math.max(1, os.cpus().length);
|
|
@@ -637,7 +749,12 @@ export function nextDelayMs(failures, busy = false) {
|
|
|
637
749
|
function messageOf(error) {
|
|
638
750
|
return error instanceof Error ? error.message : String(error);
|
|
639
751
|
}
|
|
640
|
-
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
|
+
});
|
|
641
758
|
const BuiltSchema = z.object({
|
|
642
759
|
templateId: z.string(),
|
|
643
760
|
results: z.array(WorkstationStepResultSchema),
|
|
@@ -646,6 +763,7 @@ const BuiltSchema = z.object({
|
|
|
646
763
|
});
|
|
647
764
|
const OwnedSchema = z.object({ paths: z.array(z.string()) });
|
|
648
765
|
const CLAUDE_FILE = '.claude.json';
|
|
766
|
+
const SEAT_FILES = ['config.json', 'token', 'environment.json', 'owned.json'];
|
|
649
767
|
const SERVICE_NAME = 'craftspace';
|
|
650
768
|
const LAUNCH_LABEL = 'app.craftspace.machine';
|
|
651
769
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -676,7 +794,7 @@ const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
|
|
|
676
794
|
const PROBE_EVERY_MS = 300_000;
|
|
677
795
|
const REBUILD_AFTER_MS = 21_600_000;
|
|
678
796
|
let updateWanted = null;
|
|
679
|
-
|
|
680
|
-
|
|
797
|
+
const probed = new Map();
|
|
798
|
+
const building = new Set();
|
|
681
799
|
const reports = new Map();
|
|
682
800
|
const running = new Set();
|