magic-spells 0.1.0 → 0.2.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/main.js CHANGED
@@ -1,31 +1,85 @@
1
1
  import {
2
+ AccountClient,
3
+ GRANT_ITEM,
2
4
  MagicError,
5
+ authorizeUrl,
3
6
  buildContext,
4
7
  cell,
5
8
  configFilePath,
6
9
  contextFor,
7
10
  createCredentialStore,
8
11
  createMcpServer,
12
+ createPkce,
9
13
  exitCodeFor,
10
14
  forgetProductKey,
15
+ forgetSessions,
16
+ grantExpired,
17
+ grantFromToken,
18
+ grantIdleExpiry,
19
+ isMagicError,
11
20
  jsonMode,
21
+ loadCliConfig,
12
22
  manualItem,
13
23
  maskKey,
24
+ mintSessions,
14
25
  parseGlobals,
15
26
  processIO,
16
27
  productConfig,
17
28
  productEnvVar,
29
+ productSessions,
30
+ randomState,
18
31
  readConfigFile,
32
+ readField,
33
+ readGrant,
34
+ readIndex,
19
35
  readPackageVersion,
36
+ readStr,
37
+ rec,
38
+ remintFor,
20
39
  render,
21
40
  renderError,
22
41
  resolveCredential,
42
+ revokeSessions,
23
43
  runDoctorChecks,
24
44
  runProductCli,
45
+ runWithRemint,
46
+ saveGrant,
25
47
  saveProductKey,
48
+ sessionItem,
49
+ startLoopback,
50
+ storeSessions,
26
51
  toMagicError,
27
- usage
28
- } from "./chunk-YT2Z756Z.js";
52
+ usage,
53
+ waitForDevice
54
+ } from "./chunk-JZ7XESF4.js";
55
+
56
+ // src/browser.ts
57
+ import { spawn } from "node:child_process";
58
+ import { createInterface } from "node:readline/promises";
59
+ function browserIsLocal(env, platform) {
60
+ if (env.SSH_CONNECTION) return false;
61
+ if (platform === "linux") return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
62
+ return true;
63
+ }
64
+ function openBrowser(url) {
65
+ return new Promise((resolve) => {
66
+ const platform = process.platform;
67
+ const command = platform === "darwin" ? "open" : platform === "win32" ? "rundll32" : "xdg-open";
68
+ const args = platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
69
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
70
+ child.once("error", () => resolve(false));
71
+ child.once("close", (code) => resolve(code === 0));
72
+ child.unref();
73
+ });
74
+ }
75
+ async function confirmOnTty(question) {
76
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
77
+ try {
78
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
79
+ } finally {
80
+ rl.close();
81
+ }
82
+ }
29
83
 
30
84
  // src/version.ts
31
85
  var cached;
@@ -93,9 +147,10 @@ async function doctor(shell, argv) {
93
147
  }
94
148
  rejectWorkspace("doctor", shell.opts);
95
149
  noMoreArgs("doctor", argv);
96
- const checks = [...shellChecks(shell)];
97
- for (const product of await loadProducts(shell))
98
- checks.push(...await productChecks(shell, product));
150
+ const products = await loadProducts(shell);
151
+ const checks = [...shellChecks(shell), ...await accountChecks(shell)];
152
+ const signedIn = await anyCredential(shell, products);
153
+ for (const product of products) checks.push(...await productChecks(shell, product, signedIn));
99
154
  const ok = checks.every((c) => c.ok);
100
155
  if (jsonMode(shell.opts, shell.io)) render({ ok, checks }, shell.opts, shell.io);
101
156
  else printHuman(shell, checks);
@@ -123,7 +178,79 @@ function shellChecks(shell) {
123
178
  }
124
179
  ];
125
180
  }
126
- async function productChecks(shell, product) {
181
+ async function accountChecks(shell) {
182
+ const grant = await readGrant(shell.store);
183
+ let accountUrl;
184
+ try {
185
+ accountUrl = loadCliConfig({
186
+ products: [],
187
+ env: shell.env,
188
+ file: shell.readConfig()
189
+ }).accountUrl;
190
+ } catch (err) {
191
+ const e = toMagicError(err);
192
+ return [{ name: "account", product: "account", ok: false, code: e.code, detail: e.message }];
193
+ }
194
+ const results = [];
195
+ try {
196
+ const res = await fetch(accountUrl, {
197
+ method: "HEAD",
198
+ signal: AbortSignal.timeout(REACH_TIMEOUT_MS)
199
+ });
200
+ results.push({
201
+ name: "account",
202
+ product: "account",
203
+ ok: true,
204
+ detail: `${accountUrl} answers (HTTP ${res.status})`
205
+ });
206
+ } catch (err) {
207
+ const e = toMagicError(networkish(err), {
208
+ product: "account",
209
+ networkHint: "Check MAGIC_ACCOUNT_URL and your network connection."
210
+ });
211
+ const detail = e.hint ? `${e.message} \u2014 ${e.hint}` : e.message;
212
+ results.push(
213
+ grant ? { name: "account", product: "account", ok: false, code: e.code, detail } : { name: "account", product: "account", ok: true, warn: true, detail }
214
+ );
215
+ }
216
+ if (!grant) {
217
+ results.push({
218
+ name: "account grant",
219
+ product: "account",
220
+ ok: true,
221
+ warn: true,
222
+ detail: "Not signed in (run `magic login`); product keys only."
223
+ });
224
+ } else if (grantExpired(grant)) {
225
+ results.push({
226
+ name: "account grant",
227
+ product: "account",
228
+ ok: false,
229
+ code: "not_logged_in",
230
+ detail: "Your sign-in has ended \u2014 run `magic login`."
231
+ });
232
+ } else {
233
+ const who = grant.user ? `${grant.user.display_name} <${grant.user.email}>` : "signed in";
234
+ results.push({
235
+ name: "account grant",
236
+ product: "account",
237
+ ok: true,
238
+ detail: `${who} until ${grant.expires_at.slice(0, 10)} (idle sign-out after ${grantIdleExpiry(grant).slice(0, 10)})`,
239
+ data: { user: grant.user ?? null, expires_at: grant.expires_at }
240
+ });
241
+ }
242
+ return results;
243
+ }
244
+ async function anyCredential(shell, products) {
245
+ if (await readGrant(shell.store)) return true;
246
+ if (Object.keys(await readIndex(shell.store)).length > 0) return true;
247
+ for (const p of products) {
248
+ if (shell.env[productEnvVar(p.id, "KEY")]?.trim()) return true;
249
+ if (await shell.store.locate(manualItem(p.id)) !== null) return true;
250
+ }
251
+ return false;
252
+ }
253
+ async function productChecks(shell, product, signedIn) {
127
254
  const id = product.id;
128
255
  const networkHint = `Check ${productEnvVar(id, "URL")} and your network connection.`;
129
256
  const fail = (name, err) => {
@@ -160,22 +287,39 @@ async function productChecks(shell, product) {
160
287
  }
161
288
  let session;
162
289
  try {
163
- session = await resolveCredential(product, { env: shell.env, store: shell.store });
290
+ const workspace = config.defaultWorkspace[id];
291
+ session = await resolveCredential(product, {
292
+ env: shell.env,
293
+ store: shell.store,
294
+ ...workspace ? { workspace } : {}
295
+ });
164
296
  } catch (err) {
165
- results.push(fail(`${id} key`, err));
297
+ const failed = fail(`${id} key`, err);
298
+ const soft = failed.code === "usage" || failed.code === "not_logged_in" && signedIn;
299
+ results.push(soft ? { ...failed, ok: true, warn: true } : failed);
166
300
  return results;
167
301
  }
302
+ let ctx = contextFor(product, config, session);
303
+ if (session.source === "account" && Date.parse(session.expires_at ?? "") <= Date.now()) {
304
+ try {
305
+ ctx = await remintFor(product, shell.store)(ctx) ?? ctx;
306
+ } catch (err) {
307
+ results.push(fail(`${id} key`, err));
308
+ return results;
309
+ }
310
+ }
168
311
  results.push({
169
312
  name: `${id} key`,
170
313
  product: id,
171
314
  ok: true,
172
- detail: `${maskKey(session.key, product.credential)} from ${session.source === "env" ? "the environment" : "the store"}`
315
+ detail: `${maskKey(ctx.session.key, product.credential)} from ${keySource(session.source)}${session.workspace ? ` (workspace ${session.workspace.handle})` : ""}`
173
316
  });
174
- results.push(
175
- ...await runDoctorChecks(product.doctor ?? [], contextFor(product, config, session), id)
176
- );
317
+ results.push(...await runDoctorChecks(product.doctor ?? [], ctx, id));
177
318
  return results;
178
319
  }
320
+ function keySource(source) {
321
+ return source === "env" ? "the environment" : source === "account" ? "your sign-in" : "a pasted key";
322
+ }
179
323
  function networkish(err) {
180
324
  if (err instanceof Error && err.cause instanceof Error) {
181
325
  return new Error(`${err.message}: ${err.cause.message}`);
@@ -193,15 +337,23 @@ function printHuman(shell, checks) {
193
337
  }
194
338
 
195
339
  // src/commands/login.ts
196
- var LOGIN_HELP = `Usage: magic login --product-key [<product>]
340
+ var LOGIN_HELP = `Usage: magic login [--device] [--product <product>] [--yes]
341
+ magic login --product-key [<product>]
197
342
 
198
- Stores a product key (from a hidden prompt, or stdin when piped) in the OS keychain,
199
- or a 0600 file where there is none. Without <product>, the key's prefix picks it.
200
- Account sign-in (\`magic login\` alone) arrives in a later release.
343
+ Signs in to your Magic Spells account, then mints a short-lived key in each product
344
+ for every workspace you belong to. Opens your browser when one is on this machine;
345
+ otherwise (or with --device) prints a code to enter on any device.
346
+
347
+ --device sign in with a code instead of a browser on this machine
348
+ --product <product> mint (or re-mint) one product's keys
349
+ --yes, -y replace an existing sign-in without asking
350
+ --product-key store a pasted product key instead (from a hidden prompt or
351
+ stdin; for CI). Without <product>, the key's prefix picks it.
201
352
  `;
202
353
  var LOGOUT_HELP = `Usage: magic logout [--product <product>]
203
354
 
204
- Forgets the stored key of every product, or of one.
355
+ Revokes your product keys and your sign-in, and deletes every stored credential.
356
+ With --product, only that product's keys (you stay signed in).
205
357
  `;
206
358
  async function login(shell, argv) {
207
359
  const args = [...argv];
@@ -210,12 +362,276 @@ async function login(shell, argv) {
210
362
  return 0;
211
363
  }
212
364
  rejectWorkspace("login", shell.opts);
213
- if (!takeSwitch(args, "product-key")) {
365
+ if (takeSwitch(args, "product-key")) return productKeyLogin(shell, args);
366
+ const device = takeSwitch(args, "device");
367
+ const only = takeValue(args, "product");
368
+ noMoreArgs("login", args);
369
+ if (only) chooseEntries(shell, [only]);
370
+ const all = await loadProducts(shell);
371
+ const targets = all.filter((p) => p.sessions && (!only || p.id === only));
372
+ if (only && targets.length === 0) {
214
373
  throw usage(
215
- "Account login is not available yet.",
216
- "Run `magic login --product-key [<product>]` to store a product key."
374
+ `${only} does not mint keys from your account.`,
375
+ `Run \`magic login --product-key ${only}\` to store a key.`
217
376
  );
218
377
  }
378
+ const config = loadCliConfig({
379
+ products: all.map((p) => ({ id: p.id, apiUrl: p.apiUrl })),
380
+ env: shell.env,
381
+ file: shell.readConfig()
382
+ });
383
+ const interrupt = interruptSignal(shell);
384
+ try {
385
+ const old = await readGrant(shell.store);
386
+ const live = old && !grantExpired(old) ? old : null;
387
+ let grant;
388
+ let previous = null;
389
+ if (only && live) {
390
+ grant = live;
391
+ } else {
392
+ if (live) await confirmReplace(shell, live);
393
+ previous = old ? await snapshot(shell, all, old) : null;
394
+ const token = await signIn(shell, config, device, interrupt.signal);
395
+ grant = grantFromToken(token, config.accountUrl);
396
+ await saveGrant(shell.store, grant);
397
+ }
398
+ const minted = await mintAll(shell, config, targets, grant, interrupt.signal);
399
+ grant = await rememberUser(shell, config, minted, grant);
400
+ if (previous) await retire(shell, config, previous, minted);
401
+ const result = loginResult(grant, minted);
402
+ report(shell, result);
403
+ if (jsonMode(shell.opts, shell.io)) render(result, shell.opts, shell.io);
404
+ return 0;
405
+ } finally {
406
+ interrupt.dispose();
407
+ }
408
+ }
409
+ function interruptSignal(shell) {
410
+ if (shell.login.interrupt) return { signal: shell.login.interrupt, dispose: () => {
411
+ } };
412
+ const controller = new AbortController();
413
+ const onSigint = () => controller.abort();
414
+ process.once("SIGINT", onSigint);
415
+ return { signal: controller.signal, dispose: () => process.off("SIGINT", onSigint) };
416
+ }
417
+ async function confirmReplace(shell, grant) {
418
+ if (shell.opts.yes) return;
419
+ const who = grant.user ? `${grant.user.display_name} (${grant.user.email})` : "another person";
420
+ if (!shell.confirm) {
421
+ throw usage(`Already logged in as ${who}.`, "Re-run with --yes to replace that sign-in.");
422
+ }
423
+ if (!await shell.confirm(`Already logged in as ${who}. Replace that sign-in? [y/N] `)) {
424
+ throw cancelled();
425
+ }
426
+ }
427
+ async function signIn(shell, config, forceDevice, signal) {
428
+ const client = new AccountClient({
429
+ accountUrl: config.accountUrl,
430
+ clientId: config.accountClientId
431
+ });
432
+ void client.clientId;
433
+ const local = browserIsLocal(shell.env, shell.platform);
434
+ return !forceDevice && local ? loopbackSignIn(shell, client, signal) : deviceSignIn(shell, client, local, signal);
435
+ }
436
+ async function loopbackSignIn(shell, client, signal) {
437
+ const pkce = createPkce();
438
+ const state = randomState();
439
+ const loopback = await startLoopback({
440
+ state,
441
+ signal,
442
+ ...shell.login.loopbackTimeoutMs !== void 0 ? { timeoutMs: shell.login.loopbackTimeoutMs } : {},
443
+ exchange: (code, redirectUri, exchangeSignal) => client.exchangeCode({ code, verifier: pkce.verifier, redirectUri, signal: exchangeSignal })
444
+ });
445
+ const url = authorizeUrl({
446
+ accountUrl: client.accountUrl,
447
+ clientId: client.clientId,
448
+ redirectUri: loopback.redirectUri,
449
+ state,
450
+ challenge: pkce.challenge
451
+ });
452
+ if (await shell.openBrowser(url)) {
453
+ note(shell, "Opened your browser to sign in to Magic Spells. Waiting for you to finish\u2026");
454
+ } else {
455
+ shell.io.err(`Open this page to sign in to Magic Spells:
456
+
457
+ ${url}
458
+
459
+ `);
460
+ }
461
+ return loopback.result;
462
+ }
463
+ async function deviceSignIn(shell, client, browserLocal, signal) {
464
+ const start = await client.startDevice(signal);
465
+ const bold = (t) => shell.opts.color === false ? t : `\x1B[1m${t}\x1B[22m`;
466
+ const big = start.user_code.split("").join(" ");
467
+ shell.io.err(
468
+ `To sign in, open this page on any device:
469
+
470
+ ${start.verification_uri}
471
+
472
+ and enter the code:
473
+
474
+ ${bold(big)}
475
+
476
+ `
477
+ );
478
+ if (browserLocal && start.verification_uri_complete) {
479
+ if (await shell.openBrowser(start.verification_uri_complete)) {
480
+ note(shell, "Opened your browser.");
481
+ }
482
+ }
483
+ const countdown = shell.io.isTTY() && !shell.opts.quiet;
484
+ if (!countdown) {
485
+ note(shell, `Waiting for approval (the code expires in ${minutes(start.expires_in)})\u2026`);
486
+ }
487
+ try {
488
+ return await waitForDevice(client, start, {
489
+ signal,
490
+ ...shell.login.sleep ? { sleep: shell.login.sleep } : {},
491
+ ...shell.login.now ? { now: shell.login.now } : {},
492
+ ...countdown ? { onTick: (s) => shell.io.err(`\rWaiting for approval\u2026 ${clock(s)} left `) } : {}
493
+ });
494
+ } finally {
495
+ if (countdown) shell.io.err("\r\x1B[K");
496
+ }
497
+ }
498
+ async function mintAll(shell, config, products, grant, signal) {
499
+ const answers = await Promise.all(
500
+ products.map(async (product) => {
501
+ try {
502
+ const res = await mintSessions(product, {
503
+ apiUrl: config.products[product.id].apiUrl,
504
+ grant: grant.access_token,
505
+ signal
506
+ });
507
+ return { product, res };
508
+ } catch (err) {
509
+ return { product, error: toMagicError(err, { product: product.id }) };
510
+ }
511
+ })
512
+ );
513
+ const out = [];
514
+ for (const a of answers) {
515
+ if (!a.res) {
516
+ out.push({ product: a.product, error: a.error });
517
+ } else if (a.res.sessions.length === 0) {
518
+ out.push({
519
+ product: a.product,
520
+ error: new MagicError("permission_denied", `You have no ${a.product.title} workspaces.`, {
521
+ product: a.product.id,
522
+ details: { reason: "no_workspaces" }
523
+ })
524
+ });
525
+ } else {
526
+ const entry = await storeSessions(shell.store, a.product.id, a.res);
527
+ out.push({
528
+ product: a.product,
529
+ entry,
530
+ sessions: a.res.sessions,
531
+ ...a.res.user ? { user: a.res.user } : {}
532
+ });
533
+ }
534
+ }
535
+ return out;
536
+ }
537
+ async function rememberUser(shell, config, minted, grant) {
538
+ let user = minted.find((m) => m.user)?.user ?? grant.user;
539
+ for (const m of minted) {
540
+ if (user || !m.sessions) continue;
541
+ try {
542
+ const op = (await m.product.load()).find((o) => o.name === `${m.product.id}.whoami`);
543
+ if (!op) continue;
544
+ const ctx = contextFor(m.product, config, m.sessions[0]);
545
+ const identity = await runWithRemint(
546
+ op,
547
+ op.input.parse({}),
548
+ ctx,
549
+ remintFor(m.product, shell.store)
550
+ );
551
+ const u = rec(readField(identity, "user"));
552
+ const email = readStr(u.email);
553
+ if (email) {
554
+ user = {
555
+ id: readStr(u.id) ?? "",
556
+ display_name: readStr(u.display_name) ?? readStr(u.name) ?? email,
557
+ email
558
+ };
559
+ }
560
+ } catch {
561
+ }
562
+ }
563
+ const touched = {
564
+ ...grant,
565
+ ...user ? { user } : {},
566
+ ...minted.some((m) => m.sessions) ? { last_used_at: (/* @__PURE__ */ new Date()).toISOString() } : {}
567
+ };
568
+ await saveGrant(shell.store, touched);
569
+ return touched;
570
+ }
571
+ async function snapshot(shell, all, grant) {
572
+ const sessions = [];
573
+ for (const product of all) {
574
+ const s = await productSessions(shell.store, product.id);
575
+ if (s.length > 0) sessions.push({ product, sessions: s });
576
+ }
577
+ return { grant, sessions };
578
+ }
579
+ async function retire(shell, config, previous, minted) {
580
+ for (const { product, sessions } of previous.sessions) {
581
+ await revokeProduct(product, sessions, config).catch(() => {
582
+ });
583
+ if (!minted.some((m) => m.product.id === product.id && m.sessions)) {
584
+ await forgetSessions(shell.store, product.id);
585
+ }
586
+ }
587
+ await new AccountClient({ accountUrl: previous.grant.account_url }).revoke(previous.grant.access_token).catch(() => {
588
+ });
589
+ }
590
+ function loginResult(grant, minted) {
591
+ const products = {};
592
+ for (const m of minted) {
593
+ products[m.product.id] = m.entry ? { workspaces: m.entry.workspaces.map((w) => w.handle), default: m.entry.default ?? null } : { error: m.error.toJSON() };
594
+ }
595
+ return {
596
+ account: {
597
+ user: grant.user ?? null,
598
+ expires_at: grant.expires_at,
599
+ account_url: grant.account_url
600
+ },
601
+ products
602
+ };
603
+ }
604
+ function report(shell, result) {
605
+ if (shell.opts.quiet) return;
606
+ const u = result.account.user;
607
+ const lines = [u ? `Logged in as ${u.display_name} (${u.email}).` : "Logged in."];
608
+ const width = Math.max(...Object.keys(result.products).map((id) => id.length));
609
+ for (const [id, p] of Object.entries(result.products)) {
610
+ let line;
611
+ if ("error" in p) {
612
+ line = `skipped: ${p.error.message}${p.error.hint ? ` ${p.error.hint}` : ""}`;
613
+ } else {
614
+ const n = p.workspaces.length;
615
+ const which = `${n} workspace${n === 1 ? "" : "s"}: ${p.workspaces.join(", ")}`;
616
+ line = p.default ? `${which} (default)` : `${which} (use --workspace to pick one)`;
617
+ }
618
+ lines.push(` ${id.padEnd(width)} ${line}`);
619
+ }
620
+ shell.io.err(`${lines.join("\n")}
621
+ `);
622
+ }
623
+ function note(shell, line) {
624
+ if (!shell.opts.quiet) shell.io.err(`${line}
625
+ `);
626
+ }
627
+ function minutes(seconds) {
628
+ const m = Math.round(seconds / 60);
629
+ return m === 1 ? "1 minute" : `${m} minutes`;
630
+ }
631
+ function clock(seconds) {
632
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
633
+ }
634
+ async function productKeyLogin(shell, args) {
219
635
  if (args.some((a) => a.includes("_"))) {
220
636
  throw usage(
221
637
  "Keys are never read from arguments.",
@@ -241,6 +657,21 @@ async function login(shell, argv) {
241
657
  );
242
658
  return 0;
243
659
  }
660
+ function byPrefix(products, key) {
661
+ const match = products.find((p) => key.startsWith(p.credential));
662
+ if (match) return match;
663
+ throw usage(
664
+ "The key prefix matches no product.",
665
+ `Known prefixes: ${products.map((p) => `${p.credential} (${p.id})`).join(", ")}.`
666
+ );
667
+ }
668
+ function envShadowNote(shell, product) {
669
+ const envVar = productEnvVar(product.id, "KEY");
670
+ if (shell.env[envVar]?.trim() && !shell.opts.quiet) {
671
+ shell.io.err(`note: ${envVar} is set and takes precedence over the stored key.
672
+ `);
673
+ }
674
+ }
244
675
  async function logout(shell, argv) {
245
676
  const args = [...argv];
246
677
  if (wantsHelp(args)) {
@@ -250,28 +681,65 @@ async function logout(shell, argv) {
250
681
  rejectWorkspace("logout", shell.opts);
251
682
  const one = takeValue(args, "product");
252
683
  noMoreArgs("logout", args);
253
- const removed = [];
254
- for (const product of await loadProducts(shell, one ? [one] : void 0)) {
255
- if (await shell.store.locate(manualItem(product.id)) !== null) removed.push(product.id);
684
+ const chosen = await loadProducts(shell, one ? [one] : void 0);
685
+ const grant = await readGrant(shell.store);
686
+ const result = { removed: [], revoked: [], account: "none" };
687
+ const errors = {};
688
+ for (const product of chosen) {
689
+ const sessions = await productSessions(shell.store, product.id);
690
+ if (sessions.length > 0 && product.sessions) {
691
+ try {
692
+ await revokeProduct(product, sessions, productConfig(product, shell));
693
+ result.revoked.push(product.id);
694
+ } catch (err) {
695
+ errors[product.id] = toMagicError(err, { product: product.id }).toJSON();
696
+ }
697
+ }
698
+ const items = await forgetSessions(shell.store, product.id);
699
+ const hadManual = await shell.store.locate(manualItem(product.id)) !== null;
256
700
  await forgetProductKey(product, shell.store);
701
+ if (items.length > 0 || hadManual) result.removed.push(product.id);
257
702
  envShadowNote(shell, product);
258
703
  }
259
- render({ removed }, shell.opts, shell.io);
704
+ if (one) {
705
+ result.account = grant ? "kept" : "none";
706
+ } else if (grant) {
707
+ try {
708
+ result.account = await new AccountClient({ accountUrl: grant.account_url }).revoke(
709
+ grant.access_token
710
+ );
711
+ } catch (err) {
712
+ result.account = "failed";
713
+ errors.account = toMagicError(err, { product: "account" }).toJSON();
714
+ }
715
+ await shell.store.delete(GRANT_ITEM);
716
+ }
717
+ if (Object.keys(errors).length > 0) result.errors = errors;
718
+ if (!shell.opts.quiet) {
719
+ if (result.account === "unsupported") {
720
+ shell.io.err(
721
+ "note: Account does not offer grant revocation yet; the sign-in was forgotten here.\n"
722
+ );
723
+ }
724
+ for (const [id, e] of Object.entries(errors)) {
725
+ shell.io.err(`note: ${id}: could not revoke server-side (${e.message}); forgotten here.
726
+ `);
727
+ }
728
+ }
729
+ render(result, shell.opts, shell.io);
260
730
  return 0;
261
731
  }
262
- function byPrefix(products, key) {
263
- const match = products.find((p) => key.startsWith(p.credential));
264
- if (match) return match;
265
- throw usage(
266
- "The key prefix matches no product.",
267
- `Known prefixes: ${products.map((p) => `${p.credential} (${p.id})`).join(", ")}.`
268
- );
269
- }
270
- function envShadowNote(shell, product) {
271
- const envVar = productEnvVar(product.id, "KEY");
272
- if (shell.env[envVar]?.trim() && !shell.opts.quiet) {
273
- shell.io.err(`note: ${envVar} is set and takes precedence over the stored key.
274
- `);
732
+ async function revokeProduct(product, sessions, config) {
733
+ for (const s of sessions) {
734
+ try {
735
+ await revokeSessions(product, { apiUrl: config.products[product.id].apiUrl, key: s.key });
736
+ return;
737
+ } catch (err) {
738
+ if (isMagicError(err) && (err.code === "auth_invalid" || err.code === "auth_expired")) {
739
+ continue;
740
+ }
741
+ throw err;
742
+ }
275
743
  }
276
744
  }
277
745
 
@@ -295,7 +763,13 @@ async function buildMcp(shell, argv, signal) {
295
763
  try {
296
764
  const module = await entry.load();
297
765
  const operations = await module.load();
298
- products.push({ module, operations, context: contextSource(shell, module, signal) });
766
+ if (operations.length === 0 && !module.mcp && !ids?.includes(entry.id)) continue;
767
+ products.push({
768
+ module,
769
+ operations,
770
+ context: contextSource(shell, module, signal),
771
+ remint: remintFor(module, shell.store)
772
+ });
299
773
  } catch (err) {
300
774
  skip(entry.id, err);
301
775
  }
@@ -374,12 +848,24 @@ function guardStdout() {
374
848
  // src/commands/whoami.ts
375
849
  async function whoami(shell, argv) {
376
850
  if (wantsHelp(argv)) {
377
- shell.io.err("Usage: magic whoami [--json]\n\nWho each product key belongs to.\n");
851
+ shell.io.err(
852
+ "Usage: magic whoami [--json]\n\nYour account, and your workspaces and keys in each product.\n"
853
+ );
378
854
  return 0;
379
855
  }
380
856
  rejectWorkspace("whoami", shell.opts);
381
857
  noMoreArgs("whoami", argv);
382
- const result = { account: null, products: {} };
858
+ const grant = await readGrant(shell.store);
859
+ const result = {
860
+ account: grant ? {
861
+ user: grant.user ?? null,
862
+ grant_id: grant.grant_id,
863
+ expires_at: grant.expires_at,
864
+ idle_expires_at: grantIdleExpiry(grant),
865
+ account_url: grant.account_url
866
+ } : null,
867
+ products: {}
868
+ };
383
869
  let firstError;
384
870
  for (const product of await loadProducts(shell)) {
385
871
  const entry = await identify(shell, product);
@@ -389,36 +875,87 @@ async function whoami(shell, argv) {
389
875
  if (jsonMode(shell.opts, shell.io)) render(result, shell.opts, shell.io);
390
876
  else printHuman2(shell, result);
391
877
  if (firstError) return exitCodeFor(firstError.code);
392
- if (Object.values(result.products).every((p) => p.source === "none")) {
393
- if (!shell.opts.quiet) shell.io.err("No product keys. Run `magic login --product-key`.\n");
878
+ if (!result.account && Object.values(result.products).every((p) => p.source === "none")) {
879
+ if (!shell.opts.quiet) shell.io.err("Not logged in. Run `magic login`.\n");
394
880
  return exitCodeFor("not_logged_in");
395
881
  }
396
882
  return 0;
397
883
  }
398
884
  async function identify(shell, product) {
399
- const source = shell.env[productEnvVar(product.id, "KEY")]?.trim() ? "env" : storeLabel(await shell.store.locate(manualItem(product.id)));
400
- if (source === "none") return { identity: { source, key: null, identity: null } };
401
- let key = null;
885
+ const id = product.id;
886
+ const sessions = await productSessions(shell.store, id);
887
+ const index = (await readIndex(shell.store))[id];
888
+ const envSet = Boolean(shell.env[productEnvVar(id, "KEY")]?.trim());
889
+ const manualAt = await shell.store.locate(manualItem(id));
890
+ const source = envSet ? "env" : sessions.length > 0 ? "account" : manualAt ? "manual" : "none";
891
+ const stored = source === "account" ? sessions[0].workspace.handle : null;
892
+ const base = {
893
+ source,
894
+ stored_in: source === "account" ? storeLabel(await shell.store.locate(sessionItem(id, stored))) : source === "manual" ? storeLabel(manualAt) : null,
895
+ key: null,
896
+ default: index?.default ?? null,
897
+ workspaces: workspaces(product, sessions, index?.workspaces ?? []),
898
+ identity: null
899
+ };
900
+ if (source === "none") return { identity: base };
901
+ let session;
902
+ const config = productConfig(product, shell);
903
+ try {
904
+ const workspace = config.defaultWorkspace[id];
905
+ session = await resolveCredential(product, {
906
+ env: shell.env,
907
+ store: shell.store,
908
+ ...workspace ? { workspace } : {}
909
+ });
910
+ } catch (err) {
911
+ const error = toMagicError(err, { product: id });
912
+ if (error.code === "usage") return { identity: base };
913
+ return { identity: { ...base, error: error.toJSON() }, error };
914
+ }
915
+ const identity = { ...base, key: maskKey(session.key, product.credential) };
402
916
  try {
403
- const session = await resolveCredential(product, { env: shell.env, store: shell.store });
404
- key = maskKey(session.key, product.credential);
405
- const op = (await product.load()).find((o) => o.name === `${product.id}.whoami`);
406
- if (!op) return { identity: { source, key, identity: null } };
407
- const config = productConfig(product, shell);
408
- const identity = await op.run(op.input.parse({}), contextFor(product, config, session));
409
- return { identity: { source, key, identity } };
917
+ const op = (await product.load()).find((o) => o.name === `${id}.whoami`);
918
+ if (!op) return { identity };
919
+ const ctx = contextFor(product, config, session);
920
+ const who = await runWithRemint(op, op.input.parse({}), ctx, remintFor(product, shell.store));
921
+ return { identity: { ...identity, identity: who } };
410
922
  } catch (err) {
411
- const error = toMagicError(err, { product: product.id });
412
- return { identity: { source, key, identity: null, error: error.toJSON() }, error };
923
+ const error = toMagicError(err, { product: id });
924
+ return { identity: { ...identity, error: error.toJSON() }, error };
413
925
  }
414
926
  }
927
+ function workspaces(product, sessions, listed) {
928
+ return listed.map((w) => {
929
+ const s = sessions.find((x) => x.workspace?.handle === w.handle);
930
+ return {
931
+ handle: w.handle,
932
+ name: w.name,
933
+ role: w.role,
934
+ key: s ? maskKey(s.key, product.credential) : null,
935
+ expires_at: s?.expires_at ?? null
936
+ };
937
+ });
938
+ }
415
939
  function printHuman2(shell, result) {
416
940
  const lines = [];
941
+ const a = result.account;
942
+ if (a) {
943
+ lines.push(a.user ? `${a.user.display_name} <${a.user.email}>` : "Signed in");
944
+ lines.push(
945
+ ` signed in until ${day(a.expires_at)}; ends after ${day(a.idle_expires_at)} unused`
946
+ );
947
+ } else {
948
+ lines.push("Not signed in to a Magic Spells account.");
949
+ }
950
+ lines.push("");
417
951
  for (const [id, p] of Object.entries(result.products)) {
418
- const rows = [
419
- ["source", p.source],
420
- ...p.key ? [["key", p.key]] : []
421
- ];
952
+ const rows = [["source", p.source]];
953
+ if (p.key) rows.push(["key", p.key]);
954
+ for (const w of p.workspaces) {
955
+ const mark = w.handle === p.default ? " (default)" : "";
956
+ const expires = w.expires_at ? `, key expires ${w.expires_at}` : "";
957
+ rows.push([w.handle, `${w.name} \xB7 ${w.role}${w.key ? ` \xB7 ${w.key}` : ""}${mark}${expires}`]);
958
+ }
422
959
  if (p.identity && typeof p.identity === "object") {
423
960
  for (const [k, v] of Object.entries(p.identity)) rows.push([k, cell(v)]);
424
961
  }
@@ -428,13 +965,26 @@ function printHuman2(shell, result) {
428
965
  }
429
966
  shell.io.out(lines.join("\n"));
430
967
  }
968
+ function day(iso) {
969
+ return iso.slice(0, 10);
970
+ }
431
971
 
432
972
  // src/products.ts
433
973
  var PRODUCTS = [
434
974
  {
435
975
  id: "pyramid",
436
976
  title: "Pyramid \u2014 projects and tasks",
437
- load: async () => (await import("./src-4S3XHPWY.js")).default
977
+ load: async () => (await import("./src-L2I546M7.js")).default
978
+ },
979
+ {
980
+ id: "sites",
981
+ title: "Sites \u2014 sign-in only until its commands arrive",
982
+ load: async () => (await import("./src-DJYPPJFM.js")).default
983
+ },
984
+ {
985
+ id: "marketing",
986
+ title: "Marketing \u2014 sign-in only until its commands arrive",
987
+ load: async () => (await import("./src-KJGFPRQP.js")).default
438
988
  }
439
989
  ];
440
990
 
@@ -484,10 +1034,10 @@ async function readAll(stream) {
484
1034
 
485
1035
  // src/main.ts
486
1036
  var COMMANDS = {
487
- login: { summary: "store a product key: login --product-key [<product>]", run: login },
488
- logout: { summary: "forget stored keys: logout [--product <product>]", run: logout },
489
- whoami: { summary: "who each product key belongs to", run: whoami },
490
- doctor: { summary: "check the install, the keys and each product", run: doctor },
1037
+ login: { summary: "sign in and mint product keys: login [--device] [--product <p>]", run: login },
1038
+ logout: { summary: "revoke and forget credentials: logout [--product <p>]", run: logout },
1039
+ whoami: { summary: "your account and your workspaces in each product", run: whoami },
1040
+ doctor: { summary: "check the install, the sign-in and each product", run: doctor },
491
1041
  mcp: { summary: "serve every product\u2019s tools over stdio: mcp [--products a,b]", run: serveMcp }
492
1042
  };
493
1043
  function help(products) {
@@ -544,7 +1094,11 @@ async function run(argv, deps = {}) {
544
1094
  entries: products,
545
1095
  opts,
546
1096
  readConfig,
547
- readSecret: deps.readSecret ?? readSecret
1097
+ readSecret: deps.readSecret ?? readSecret,
1098
+ openBrowser: deps.openBrowser ?? openBrowser,
1099
+ confirm: deps.confirm !== void 0 ? deps.confirm : process.stdin.isTTY ? confirmOnTty : null,
1100
+ platform: deps.platform ?? process.platform,
1101
+ login: deps.login ?? {}
548
1102
  };
549
1103
  return await command.run(shell, parsed.rest);
550
1104
  } catch (err) {