@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/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
- home(): string;
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<Machine>;
9
- workDir(): Promise<string | null>;
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
- beatOnce(say?: (line: string) => void): Promise<boolean>;
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
- home() {
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
- await mkdir(machine.home(), { recursive: true, mode: 0o700 });
39
- await writeConfig({ url, machineId: answer.machine.id, organization: toSpaceSlug(answer.organizationName) });
40
- await writeFile(tokenPath(), token, { mode: 0o600 });
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
- const organization = (await readConfig())?.organization;
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 readConfig())?.url ?? DEFAULT_URL;
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 readConfig())?.url ?? DEFAULT_URL;
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 { url: where, token, machine: signed };
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 found = await withAuth(await signedInOrAsk(), (auth) => call({ ...auth, method: 'GET', path: '/api/machines/me', schema: MachineSchema }));
81
- return { machine: found.value, url: found.auth.url };
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 beatOnce(say) {
84
- const { url, token, organization } = await requireSignedIn();
85
- const built = await readEnvironment();
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.home(), workDir }).catch(() => []);
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.beatOnce();
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 signedInOrAsk();
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 config = await readConfig();
175
- const token = await readToken();
176
- if (config && token) {
177
- await call({ url: config.url, token, method: 'POST', path: '/api/machines/logout', schema: z.object({}) }).catch(() => undefined);
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 removeAgentConfigs();
226
+ await unwireRepos();
180
227
  await uninstallService();
181
- await rm(machine.home(), { recursive: true, force: true });
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
- if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
247
- return probed.tools;
248
- const dirs = clonedDirs(await readEnvironment());
249
- const first = await probe.tools({ dirs });
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 = { at: Date.now(), tools: found };
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 = true;
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 = false;
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(machine.home(), { recursive: true, mode: 0o700 });
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.set(next.id, next);
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
- function drainReports() {
333
- const pending = [...reports.values()];
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
- reports.delete(item.id);
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.home(), 'id_ed25519');
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.home(), { recursive: true, mode: 0o700 });
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(machine.home(), { recursive: true, mode: 0o700 });
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
- return ConfigSchema.parse(JSON.parse(raw));
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.home(), 'sessions');
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
- async function writeAuthorizedKeys(keys) {
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 before = current.split(KEYS_BEGIN)[0] ?? '';
432
- const after = current.includes(KEYS_END) ? (current.split(KEYS_END)[1] ?? '') : '';
433
- const block = keys.length === 0 ? '' : `${KEYS_BEGIN}\n${keys.join('\n')}\n${KEYS_END}\n`;
434
- const next = `${trimEnd(before)}${trimEnd(before) === '' ? '' : '\n'}${block}${after.replace(/^\n/, '')}`;
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
- projects[dir] = { ...here, hasTrustDialogAccepted: true, mcpServers: { ...servers, craftspace: server } };
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 = { ...found.mcpServers };
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 = [...(await readOwned())];
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 unwireRepos();
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(machine.home(), 'config.json');
723
+ function configPath(home) {
724
+ return path.join(home, 'config.json');
613
725
  }
614
- function tokenPath() {
615
- return path.join(machine.home(), 'token');
726
+ function tokenPath(home) {
727
+ return path.join(home, 'token');
616
728
  }
617
- function ownedPath() {
618
- return path.join(machine.home(), 'owned.json');
729
+ function ownedPath(home) {
730
+ return path.join(home, 'owned.json');
619
731
  }
620
- function environmentPath() {
621
- return path.join(machine.home(), 'environment.json');
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({ url: z.string(), machineId: z.string(), organization: z.string().optional() });
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
- let probed = null;
680
- let building = false;
797
+ const probed = new Map();
798
+ const building = new Set();
681
799
  const reports = new Map();
682
800
  const running = new Set();
package/dist/migrate.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export declare const migrate: {
2
+ stripGlobalMcp(): Promise<void>;
2
3
  toLatest({ home, workDir }: {
3
4
  home: string;
4
5
  workDir: string | null;