arcane-cli 1.11.2 → 1.11.4

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 CHANGED
@@ -4,9 +4,9 @@ const require = __createRequire(import.meta.url);
4
4
 
5
5
  // src/index.ts
6
6
  import { Command } from "commander";
7
- import { createRequire as createRequire2 } from "module";
7
+ import { createRequire as createRequire3 } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { dirname as dirname11, join as join20 } from "path";
9
+ import { dirname as dirname12, join as join21 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
12
  import { select as select3, confirm as confirm2, input as input3 } from "@inquirer/prompts";
@@ -1214,15 +1214,15 @@ async function syncUserTierFanout(options) {
1214
1214
  const rendered = await renderUserFanout(options.storeRoot, options.spellIds, options.fallbackDir);
1215
1215
  const desired = [...rendered.files, ...options.extraFiles ?? []];
1216
1216
  const owned = options.ownedClients;
1217
- const ownsClient = (client) => owned === void 0 || client !== void 0 && owned.includes(client);
1217
+ const ownsClient = (client2) => owned === void 0 || client2 !== void 0 && owned.includes(client2);
1218
1218
  const previous = options.previous ?? {};
1219
1219
  const dryRun = Boolean(options.dryRun);
1220
1220
  const record = {};
1221
1221
  const outcomes = [];
1222
1222
  const desiredPaths = new Set(desired.map((f) => f.relativePath));
1223
1223
  for (const id of rendered.missing) {
1224
- for (const client of SPELL_FANOUT_CLIENTS) {
1225
- const relativePath = USER_FANOUT_PATHS[client](id);
1224
+ for (const client2 of SPELL_FANOUT_CLIENTS) {
1225
+ const relativePath = USER_FANOUT_PATHS[client2](id);
1226
1226
  desiredPaths.add(relativePath);
1227
1227
  if (previous[relativePath] !== void 0) record[relativePath] = previous[relativePath];
1228
1228
  outcomes.push({ relativePath, status: "unrenderable" });
@@ -1311,15 +1311,15 @@ function describeFanoutOutcomes(outcomes, dryRun = false) {
1311
1311
  const n = { codex: 0, claude: 0, "copilot-agents": 0 };
1312
1312
  for (const o of outcomes) {
1313
1313
  if (o.status !== status) continue;
1314
- const client = clientOfFanoutPath(o.relativePath);
1315
- if (client !== void 0) n[client]++;
1314
+ const client2 = clientOfFanoutPath(o.relativePath);
1315
+ if (client2 !== void 0) n[client2]++;
1316
1316
  }
1317
1317
  return n;
1318
1318
  };
1319
1319
  const paths = (status) => outcomes.filter((o) => o.status === status).map((o) => ` ~/${o.relativePath}`).join("\n");
1320
1320
  if (counts.written > 0) {
1321
1321
  const n = byClient("written");
1322
- const breakdown = Object.keys(n).filter((client) => n[client] > 0).map((client) => `${n[client]} ${FANOUT_CLIENT_LABELS[client]}`).join(", ");
1322
+ const breakdown = Object.keys(n).filter((client2) => n[client2] > 0).map((client2) => `${n[client2]} ${FANOUT_CLIENT_LABELS[client2]}`).join(", ");
1323
1323
  lines.push(
1324
1324
  `${prefix}${dryRun ? "write" : "Wrote"} ${counts.written} client file(s): ${breakdown}.`
1325
1325
  );
@@ -1365,8 +1365,8 @@ async function inspectUserTierFanout(homeDir, record) {
1365
1365
  };
1366
1366
  for (const [relativePath, recorded] of Object.entries(record ?? {})) {
1367
1367
  health.total++;
1368
- const client = clientOfFanoutPath(relativePath);
1369
- if (client !== void 0) health.byClient[client]++;
1368
+ const client2 = clientOfFanoutPath(relativePath);
1369
+ if (client2 !== void 0) health.byClient[client2]++;
1370
1370
  const target = join(homeDir, relativePath);
1371
1371
  const state = await classifyTarget(target);
1372
1372
  if (state === "absent") {
@@ -1830,12 +1830,12 @@ function requireEnum(value, allowed, path4, issues) {
1830
1830
  }
1831
1831
  function validateOptionalClient(clients, key, property, expected, issues) {
1832
1832
  if (clients[key] === void 0) return;
1833
- const client = requireRecord(clients[key], `clients.${key}`, issues);
1834
- if (!client) return;
1833
+ const client2 = requireRecord(clients[key], `clients.${key}`, issues);
1834
+ if (!client2) return;
1835
1835
  if (expected === "string") {
1836
- requireString(client[property], `clients.${key}.${property}`, issues);
1836
+ requireString(client2[property], `clients.${key}.${property}`, issues);
1837
1837
  } else {
1838
- requireBoolean(client[property], `clients.${key}.${property}`, issues);
1838
+ requireBoolean(client2[property], `clients.${key}.${property}`, issues);
1839
1839
  }
1840
1840
  }
1841
1841
  function validateAgentDefinition(value, source) {
@@ -2002,7 +2002,7 @@ function projectAgentsDir(targetDir, scope = "repo") {
2002
2002
  }
2003
2003
 
2004
2004
  // src/modules/agent-generator.ts
2005
- import { writeFile as writeFile5, mkdir as mkdir3, readdir as readdir4 } from "fs/promises";
2005
+ import { writeFile as writeFile5, mkdir as mkdir3, readdir as readdir4, readFile as readFile6 } from "fs/promises";
2006
2006
  import { join as join3 } from "path";
2007
2007
  import { homedir as homedir2 } from "os";
2008
2008
 
@@ -2208,6 +2208,25 @@ function renderSpellRoutingSection() {
2208
2208
  | Review code or a PR | \`spell-review\` |
2209
2209
 
2210
2210
  If a spell exists for the workflow you are about to perform, invoke it \u2014 do not improvise the workflow from general knowledge, even when the user doesn't name the spell.
2211
+ `;
2212
+ }
2213
+ async function loadBranchNamingFragment(assetsDir) {
2214
+ try {
2215
+ const fragment = await readFile6(
2216
+ join3(assetsDir, ".arcane", "spells", "_fragments", "branch-naming.md"),
2217
+ "utf8"
2218
+ );
2219
+ return fragment.trim();
2220
+ } catch {
2221
+ return null;
2222
+ }
2223
+ }
2224
+ function renderBranchNamingSection(fragment) {
2225
+ if (fragment === null) return "";
2226
+ return `## Branch Naming
2227
+
2228
+ ${fragment}
2229
+
2211
2230
  `;
2212
2231
  }
2213
2232
  function renderAgentRosterSection(entries) {
@@ -2233,6 +2252,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2233
2252
  const synced = [];
2234
2253
  const skipped = [];
2235
2254
  let hasUnresolvedRoles = false;
2255
+ const branchNaming = renderBranchNamingSection(await loadBranchNamingFragment(assetsDir));
2236
2256
  const userTier = options.scope === "user";
2237
2257
  const repoOptedOut = !userTier && options.spellScope === "user";
2238
2258
  const userAgentFiles = [];
@@ -2351,7 +2371,7 @@ async function syncAgents(targetDir, assetsDir, roster, options = {}) {
2351
2371
  }
2352
2372
  if (!userTier) {
2353
2373
  const copilotSection = `${renderSpellRoutingSection()}
2354
- ${renderAgentRosterSection(resolved)}`;
2374
+ ${branchNaming}${renderAgentRosterSection(resolved)}`;
2355
2375
  const copilotMerged = await mergeIntoFile(
2356
2376
  targetDir,
2357
2377
  ".github/copilot-instructions.md",
@@ -2367,7 +2387,7 @@ ${renderAgentRosterSection(resolved)}`;
2367
2387
  }
2368
2388
  if (options.claude !== false) {
2369
2389
  const claudeSection = `${renderSpellRoutingSection()}
2370
- ${renderAgentRosterSection(resolved)}`;
2390
+ ${branchNaming}${renderAgentRosterSection(resolved)}`;
2371
2391
  const claudeMerged = await mergeIntoFile(
2372
2392
  targetDir,
2373
2393
  "CLAUDE.md",
@@ -2378,7 +2398,7 @@ ${renderAgentRosterSection(resolved)}`;
2378
2398
  }
2379
2399
  if (options.codex !== false) {
2380
2400
  const codexSection = `${renderSpellRoutingSection()}
2381
- ${renderAgentRosterSection(resolved)}`;
2401
+ ${branchNaming}${renderAgentRosterSection(resolved)}`;
2382
2402
  const codexMerged = await mergeIntoFile(
2383
2403
  targetDir,
2384
2404
  "AGENTS.md",
@@ -3314,7 +3334,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
3314
3334
  }
3315
3335
 
3316
3336
  // src/modules/push-safety.ts
3317
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile8, chmod, access as access3, rm as rm4, readdir as readdir6 } from "fs/promises";
3337
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile8, chmod, access as access3, rm as rm4, readdir as readdir6 } from "fs/promises";
3318
3338
  import { constants } from "fs";
3319
3339
  import { join as join6, resolve as resolve2, dirname as dirname3, basename } from "path";
3320
3340
  var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
@@ -3492,7 +3512,7 @@ async function hookFileMatches(cwd, hookName, body) {
3492
3512
  const hookPath = await hookFilePath(cwd, hookName);
3493
3513
  if (!await fileExists(hookPath)) return false;
3494
3514
  try {
3495
- if (await readFile6(hookPath, "utf-8") !== body) return false;
3515
+ if (await readFile7(hookPath, "utf-8") !== body) return false;
3496
3516
  } catch {
3497
3517
  return false;
3498
3518
  }
@@ -4353,8 +4373,8 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
4353
4373
  for (const file of component.files) printDryRun(`Would copy: ${file}`);
4354
4374
  }
4355
4375
  for (const id of spellIds) {
4356
- for (const client of ["codex", "claude"]) {
4357
- const relativePath = USER_FANOUT_PATHS[client](id);
4376
+ for (const client2 of ["codex", "claude"]) {
4377
+ const relativePath = USER_FANOUT_PATHS[client2](id);
4358
4378
  if (await fileExists(join7(homeDir, relativePath))) {
4359
4379
  printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
4360
4380
  } else {
@@ -4419,7 +4439,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
4419
4439
  }
4420
4440
 
4421
4441
  // src/modules/component-install.ts
4422
- import { readFile as readFile7, stat, writeFile as writeFile9 } from "fs/promises";
4442
+ import { readFile as readFile8, stat, writeFile as writeFile9 } from "fs/promises";
4423
4443
  import { join as join8 } from "path";
4424
4444
  async function planComponentInstall(component, targetDir, assetsDir, opts = {}) {
4425
4445
  const files = [];
@@ -4454,7 +4474,7 @@ async function executeInstallPlan(plan, targetDir) {
4454
4474
  const written = [];
4455
4475
  try {
4456
4476
  for (const { file, src, state } of plan.toCopy) {
4457
- const previous = state === "missing" ? null : await readFile7(join8(targetDir, file));
4477
+ const previous = state === "missing" ? null : await readFile8(join8(targetDir, file));
4458
4478
  fileHashes[file] = await copyFile(src, targetDir, file, { force: true });
4459
4479
  written.push({ file, previous });
4460
4480
  files.push(file);
@@ -4609,7 +4629,7 @@ async function runAdd(names, options, targetDir, assetsDir, packageVersion) {
4609
4629
 
4610
4630
  // src/commands/update.ts
4611
4631
  import { dirname as dirname5, join as join10 } from "path";
4612
- import { readFile as readFile8, writeFile as writeFile10 } from "fs/promises";
4632
+ import { readFile as readFile9, writeFile as writeFile10 } from "fs/promises";
4613
4633
 
4614
4634
  // src/modules/merge3.ts
4615
4635
  var CONFLICT_START = "<<<<<<< yours (edited)";
@@ -5436,8 +5456,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
5436
5456
  handled = true;
5437
5457
  } else {
5438
5458
  const [currentContent, newContent] = await Promise.all([
5439
- readFile8(join10(targetDir, file), "utf-8"),
5440
- readFile8(srcPath, "utf-8")
5459
+ readFile9(join10(targetDir, file), "utf-8"),
5460
+ readFile9(srcPath, "utf-8")
5441
5461
  ]);
5442
5462
  const result = merge3(oldVendorContent, currentContent, newContent);
5443
5463
  await writeFile10(join10(targetDir, file), result.content, "utf-8");
@@ -5625,7 +5645,7 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
5625
5645
  import { dirname as dirname7 } from "path";
5626
5646
 
5627
5647
  // src/modules/version-check.ts
5628
- import { readFile as readFile9, writeFile as writeFile11, mkdir as mkdir6 } from "fs/promises";
5648
+ import { readFile as readFile10, writeFile as writeFile11, mkdir as mkdir6 } from "fs/promises";
5629
5649
  import { dirname as dirname6, join as join11 } from "path";
5630
5650
  import { homedir as homedir3 } from "os";
5631
5651
  import { createRequire } from "module";
@@ -5644,7 +5664,7 @@ var DEFAULT_CACHE_PATH = join11(homedir3(), ".arcane", "version-cache.json");
5644
5664
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
5645
5665
  async function readCache(cachePath) {
5646
5666
  try {
5647
- const content = await readFile9(cachePath, "utf-8");
5667
+ const content = await readFile10(cachePath, "utf-8");
5648
5668
  const parsed = JSON.parse(content);
5649
5669
  if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
5650
5670
  return null;
@@ -6028,7 +6048,7 @@ async function uninstallUserTier(options, storeRoot, manifest) {
6028
6048
  }
6029
6049
 
6030
6050
  // src/commands/doctor.ts
6031
- import { access as access4, readdir as readdir9, readFile as readFile12, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
6051
+ import { access as access4, readdir as readdir9, readFile as readFile13, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
6032
6052
  import { join as join15, dirname as dirname9 } from "path";
6033
6053
 
6034
6054
  // src/config/incidents.ts
@@ -6097,7 +6117,7 @@ function evaluateIncidentGate(records) {
6097
6117
  }
6098
6118
 
6099
6119
  // src/modules/denylist-scan.ts
6100
- import { readFile as readFile10, readdir as readdir7 } from "fs/promises";
6120
+ import { readFile as readFile11, readdir as readdir7 } from "fs/promises";
6101
6121
  import { join as join13, relative } from "path";
6102
6122
  function escapeRegExp(value) {
6103
6123
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -6115,7 +6135,7 @@ async function scanFile(absolutePath, displayPath, rules) {
6115
6135
  const findings = [];
6116
6136
  let content;
6117
6137
  try {
6118
- content = await readFile10(absolutePath, "utf8");
6138
+ content = await readFile11(absolutePath, "utf8");
6119
6139
  } catch {
6120
6140
  return findings;
6121
6141
  }
@@ -6409,7 +6429,7 @@ function parseAdoRemote(url) {
6409
6429
  }
6410
6430
 
6411
6431
  // src/modules/placeholders.ts
6412
- import { readdir as readdir8, readFile as readFile11 } from "fs/promises";
6432
+ import { readdir as readdir8, readFile as readFile12 } from "fs/promises";
6413
6433
  import { join as join14 } from "path";
6414
6434
  var PLACEHOLDER_STANDARD_FILE = "spell-authoring-standards.md";
6415
6435
  var RUNTIME_PLACEHOLDERS_START = "<!-- runtime-placeholders:start -->";
@@ -6505,7 +6525,7 @@ function parseRuntimePlaceholders(standardContent) {
6505
6525
  async function scanGovernancePlaceholders(governanceDir) {
6506
6526
  let standard;
6507
6527
  try {
6508
- standard = await readFile11(join14(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
6528
+ standard = await readFile12(join14(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
6509
6529
  } catch (err) {
6510
6530
  if (err.code === "ENOENT") {
6511
6531
  return { status: "skipped", reason: `${PLACEHOLDER_STANDARD_FILE} is not installed` };
@@ -6524,7 +6544,7 @@ async function scanGovernancePlaceholders(governanceDir) {
6524
6544
  const templateDocs = [];
6525
6545
  const findings = [];
6526
6546
  for (const file of files) {
6527
- const content = await readFile11(join14(governanceDir, file), "utf8");
6547
+ const content = await readFile12(join14(governanceDir, file), "utf8");
6528
6548
  const status = parseFrontmatterStatus(content);
6529
6549
  if (status === "template") templateDocs.push(file);
6530
6550
  if (status !== "active") continue;
@@ -6590,7 +6610,7 @@ async function checkArcaneManifest(targetDir) {
6590
6610
  } catch {
6591
6611
  const selfHostedManifestPath = join15(targetDir, "src", "assets", ".arcane.json");
6592
6612
  try {
6593
- const raw = await readFile12(selfHostedManifestPath, "utf8");
6613
+ const raw = await readFile13(selfHostedManifestPath, "utf8");
6594
6614
  const manifest = JSON.parse(raw);
6595
6615
  if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
6596
6616
  return {
@@ -6609,7 +6629,7 @@ async function checkArcaneManifest(targetDir) {
6609
6629
  };
6610
6630
  }
6611
6631
  try {
6612
- const raw = await readFile12(manifestPath2, "utf8");
6632
+ const raw = await readFile13(manifestPath2, "utf8");
6613
6633
  const manifest = JSON.parse(raw);
6614
6634
  if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
6615
6635
  return {
@@ -6934,7 +6954,7 @@ async function checkMcpConfig(targetDir) {
6934
6954
  const filePath = join15(targetDir, ".mcp.json");
6935
6955
  let raw;
6936
6956
  try {
6937
- raw = await readFile12(filePath, "utf8");
6957
+ raw = await readFile13(filePath, "utf8");
6938
6958
  } catch {
6939
6959
  return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
6940
6960
  }
@@ -7017,7 +7037,7 @@ async function checkDelegations(targetDir) {
7017
7037
  const filePath = join15(targetDir, ".arcane", "delegations.json");
7018
7038
  let raw;
7019
7039
  try {
7020
- raw = await readFile12(filePath, "utf8");
7040
+ raw = await readFile13(filePath, "utf8");
7021
7041
  } catch {
7022
7042
  return { name, passed: true, blocking: false, message: "no delegations recorded" };
7023
7043
  }
@@ -7237,16 +7257,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
7237
7257
  }
7238
7258
 
7239
7259
  // src/commands/ward.ts
7240
- import { readFile as readFile14 } from "fs/promises";
7260
+ import { readFile as readFile15 } from "fs/promises";
7241
7261
 
7242
7262
  // src/modules/ward.ts
7243
- import { readdir as readdir10, readFile as readFile13 } from "fs/promises";
7263
+ import { readdir as readdir10, readFile as readFile14 } from "fs/promises";
7244
7264
  import { join as join16, relative as relative2 } from "path";
7245
7265
  async function scanFileAllRules(absolutePath, displayPath, rules) {
7246
7266
  const findings = [];
7247
7267
  let content;
7248
7268
  try {
7249
- content = await readFile13(absolutePath, "utf8");
7269
+ content = await readFile14(absolutePath, "utf8");
7250
7270
  } catch {
7251
7271
  return findings;
7252
7272
  }
@@ -7341,7 +7361,7 @@ async function runWard(repoRoot, options = {}) {
7341
7361
  const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
7342
7362
  if (findings2.length > 0) {
7343
7363
  try {
7344
- fileLines.set(displayPath, (await readFile13(absolutePath, "utf8")).split("\n"));
7364
+ fileLines.set(displayPath, (await readFile14(absolutePath, "utf8")).split("\n"));
7345
7365
  } catch {
7346
7366
  }
7347
7367
  }
@@ -7369,7 +7389,7 @@ function parseTermsFileContent(raw) {
7369
7389
  }
7370
7390
  async function readTermsFile(filePath) {
7371
7391
  try {
7372
- return parseTermsFileContent(await readFile14(filePath, "utf8"));
7392
+ return parseTermsFileContent(await readFile15(filePath, "utf8"));
7373
7393
  } catch (error) {
7374
7394
  console.warn(
7375
7395
  `\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
@@ -7579,17 +7599,705 @@ async function runBlockPush(targetDir) {
7579
7599
  }
7580
7600
  }
7581
7601
 
7602
+ // src/modules/credential-store.ts
7603
+ import { createHash as createHash2, randomBytes } from "crypto";
7604
+ var CHUNK_SIZE = 1e3;
7605
+ var MAX_CHUNKS = 64;
7606
+ var INDEX_NAME = "session/index";
7607
+ var CHUNK_PREFIX = "session/";
7608
+ var INDEX_VERSION = 1;
7609
+ var KeychainUnavailableError = class extends Error {
7610
+ constructor(operation, cause) {
7611
+ super(`The OS keychain is not available (${operation} failed).`);
7612
+ this.name = "KeychainUnavailableError";
7613
+ this.cause = cause;
7614
+ }
7615
+ };
7616
+ function sha256Hex(value) {
7617
+ return createHash2("sha256").update(value, "utf8").digest("hex");
7618
+ }
7619
+ function chunkName(gen, n) {
7620
+ return `${CHUNK_PREFIX}${gen}/${n}`;
7621
+ }
7622
+ function splitSecret(secret, size = CHUNK_SIZE) {
7623
+ const chunks = [];
7624
+ let start = 0;
7625
+ while (start < secret.length) {
7626
+ let end = Math.min(start + size, secret.length);
7627
+ if (end < secret.length) {
7628
+ const last = secret.charCodeAt(end - 1);
7629
+ if (last >= 55296 && last <= 56319) end -= 1;
7630
+ }
7631
+ chunks.push(secret.slice(start, end));
7632
+ start = end;
7633
+ }
7634
+ return chunks;
7635
+ }
7636
+ function parseIndex(raw) {
7637
+ let parsed;
7638
+ try {
7639
+ parsed = JSON.parse(raw);
7640
+ } catch {
7641
+ return null;
7642
+ }
7643
+ if (typeof parsed !== "object" || parsed === null) return null;
7644
+ const o = parsed;
7645
+ if (o["v"] !== INDEX_VERSION || typeof o["gen"] !== "string" || !/^[0-9a-f]{16}$/.test(o["gen"]) || typeof o["count"] !== "number" || !Number.isInteger(o["count"]) || o["count"] < 1 || o["count"] > MAX_CHUNKS || typeof o["sha256"] !== "string" || !/^[0-9a-f]{64}$/.test(o["sha256"])) {
7646
+ return null;
7647
+ }
7648
+ return { v: o["v"], gen: o["gen"], count: o["count"], sha256: o["sha256"] };
7649
+ }
7650
+ var ChunkedSecretStore = class {
7651
+ constructor(backend) {
7652
+ this.backend = backend;
7653
+ }
7654
+ backend;
7655
+ guard(operation, fn) {
7656
+ try {
7657
+ return fn();
7658
+ } catch (error) {
7659
+ throw new KeychainUnavailableError(operation, error);
7660
+ }
7661
+ }
7662
+ /** Reads the stored secret. Never throws for bad data; throws only when the keychain itself fails. */
7663
+ load() {
7664
+ const raw = this.guard("read", () => this.backend.get(INDEX_NAME));
7665
+ if (raw === null) return { state: "signed-out" };
7666
+ const index = parseIndex(raw);
7667
+ if (index === null) return { state: "corrupt", reason: "the index entry is not valid" };
7668
+ const parts = [];
7669
+ for (let n = 0; n < index.count; n += 1) {
7670
+ const part = this.guard("read", () => this.backend.get(chunkName(index.gen, n)));
7671
+ if (part === null) return { state: "corrupt", reason: `chunk ${n} of ${index.count} is missing` };
7672
+ parts.push(part);
7673
+ }
7674
+ const secret = parts.join("");
7675
+ if (sha256Hex(secret) !== index.sha256) {
7676
+ return { state: "corrupt", reason: "the stored pieces do not match their checksum" };
7677
+ }
7678
+ return { state: "ok", secret };
7679
+ }
7680
+ /**
7681
+ * Replaces the stored secret. The previous session stays readable until the
7682
+ * new index is written; a failure before that leaves it untouched.
7683
+ */
7684
+ save(secret) {
7685
+ if (secret.length === 0) throw new Error("Refusing to store an empty secret.");
7686
+ const gen = randomBytes(8).toString("hex");
7687
+ const chunks = splitSecret(secret);
7688
+ if (chunks.length > MAX_CHUNKS) {
7689
+ throw new Error(`Refusing to store a secret longer than ${MAX_CHUNKS * CHUNK_SIZE} characters.`);
7690
+ }
7691
+ chunks.forEach((chunk, n) => {
7692
+ this.guard("write", () => this.backend.set(chunkName(gen, n), chunk));
7693
+ });
7694
+ const index = { v: INDEX_VERSION, gen, count: chunks.length, sha256: sha256Hex(secret) };
7695
+ this.guard("write", () => this.backend.set(INDEX_NAME, JSON.stringify(index)));
7696
+ try {
7697
+ this.sweep(gen);
7698
+ } catch {
7699
+ }
7700
+ }
7701
+ /** Deletes everything this store wrote, strays included. Succeeds when nothing is stored. */
7702
+ clear() {
7703
+ this.guard("delete", () => this.backend.delete(INDEX_NAME));
7704
+ this.sweep(null);
7705
+ }
7706
+ /** Removes every chunk that does not belong to `keepGen` (all chunks when it is null). */
7707
+ sweep(keepGen) {
7708
+ const names = this.guard("list", () => this.backend.list());
7709
+ for (const name of names) {
7710
+ if (name === INDEX_NAME || !name.startsWith(CHUNK_PREFIX)) continue;
7711
+ if (keepGen !== null && name.startsWith(`${CHUNK_PREFIX}${keepGen}/`)) continue;
7712
+ this.guard("delete", () => this.backend.delete(name));
7713
+ }
7714
+ }
7715
+ };
7716
+
7717
+ // src/modules/identity-config.ts
7718
+ var IDENTITY_ENVIRONMENTS = {
7719
+ production: {
7720
+ environment: "production",
7721
+ tenantId: "9f8edbfb-291c-4c4a-b9cc-8820a04f8973",
7722
+ signInHost: "arcaneai.ciamlogin.com",
7723
+ clientId: "7cd8f05d-9979-4458-8ca2-47c67748c41f",
7724
+ discoveryUrl: "https://arcaneai.ciamlogin.com/9f8edbfb-291c-4c4a-b9cc-8820a04f8973/v2.0/.well-known/openid-configuration"
7725
+ },
7726
+ development: {
7727
+ environment: "development",
7728
+ tenantId: "b365de0e-3026-4afe-bf5b-556fccb4ffcf",
7729
+ signInHost: "arcaneaidev.ciamlogin.com",
7730
+ clientId: "651be720-83cc-452b-8346-abf20713ad12",
7731
+ discoveryUrl: "https://arcaneaidev.ciamlogin.com/b365de0e-3026-4afe-bf5b-556fccb4ffcf/v2.0/.well-known/openid-configuration"
7732
+ }
7733
+ };
7734
+ var SIGN_IN_SCOPES = ["openid", "profile", "email", "offline_access"];
7735
+ var ENVIRONMENT_VARIABLE = "ARCANE_ENVIRONMENT";
7736
+ var UnknownEnvironmentError = class extends Error {
7737
+ constructor(value) {
7738
+ super(
7739
+ `${ENVIRONMENT_VARIABLE}=${JSON.stringify(value)} is not an environment. Use "production" (the default) or "development".`
7740
+ );
7741
+ this.name = "UnknownEnvironmentError";
7742
+ }
7743
+ };
7744
+ function resolveIdentityEnvironment(env = process.env) {
7745
+ const raw = (env[ENVIRONMENT_VARIABLE] ?? "").trim().toLowerCase();
7746
+ if (raw === "" || raw === "prod" || raw === "production") return IDENTITY_ENVIRONMENTS.production;
7747
+ if (raw === "dev" || raw === "development") return IDENTITY_ENVIRONMENTS.development;
7748
+ throw new UnknownEnvironmentError(env[ENVIRONMENT_VARIABLE] ?? "");
7749
+ }
7750
+
7751
+ // src/modules/oidc.ts
7752
+ import { createServer } from "http";
7753
+ import { execFile as execFile3 } from "child_process";
7754
+ import * as client from "openid-client";
7755
+ var IdentityUnreachableError = class extends Error {
7756
+ constructor(host, cause) {
7757
+ super(`Could not reach the identity service at ${host}.`);
7758
+ this.name = "IdentityUnreachableError";
7759
+ this.cause = cause;
7760
+ }
7761
+ };
7762
+ var LoginTimeoutError = class extends Error {
7763
+ constructor() {
7764
+ super("Timed out waiting for the sign-in to finish in the browser.");
7765
+ this.name = "LoginTimeoutError";
7766
+ }
7767
+ };
7768
+ var LoginDeniedError = class extends Error {
7769
+ constructor(code) {
7770
+ super(`The identity service refused the sign-in (${code}).`);
7771
+ this.name = "LoginDeniedError";
7772
+ }
7773
+ };
7774
+ async function loadIdentityConfiguration(environment, options = {}) {
7775
+ const fetchImpl = options.fetch ?? fetch;
7776
+ let metadata;
7777
+ try {
7778
+ const response = await fetchImpl(environment.discoveryUrl, { redirect: "manual" });
7779
+ if (!response.ok) throw new Error(`discovery returned HTTP ${response.status}`);
7780
+ metadata = await response.json();
7781
+ } catch (error) {
7782
+ throw new IdentityUnreachableError(environment.signInHost, error);
7783
+ }
7784
+ if (typeof metadata.issuer !== "string" || !metadata.token_endpoint || !metadata.authorization_endpoint) {
7785
+ throw new IdentityUnreachableError(environment.signInHost, new Error("discovery document incomplete"));
7786
+ }
7787
+ const config = new client.Configuration(metadata, environment.clientId, void 0, client.None());
7788
+ if (options.fetch) config[client.customFetch] = options.fetch;
7789
+ if (options.allowInsecureHttp) client.allowInsecureRequests(config);
7790
+ return config;
7791
+ }
7792
+ function toTokenResult(tokens, previousRefreshToken) {
7793
+ const claims = tokens.claims();
7794
+ if (!claims?.sub) throw new Error("The identity service returned no account identifier.");
7795
+ const refreshToken = tokens.refresh_token ?? previousRefreshToken;
7796
+ if (!refreshToken) {
7797
+ throw new Error("The identity service returned no refresh token; nothing can be stored.");
7798
+ }
7799
+ const now = /* @__PURE__ */ new Date();
7800
+ const seconds = tokens.expiresIn() ?? 0;
7801
+ const email = typeof claims["email"] === "string" ? claims["email"] : typeof claims["preferred_username"] === "string" ? claims["preferred_username"] : null;
7802
+ return {
7803
+ refreshToken,
7804
+ sub: claims.sub,
7805
+ email,
7806
+ obtainedAt: now,
7807
+ expiresAt: new Date(now.getTime() + seconds * 1e3)
7808
+ };
7809
+ }
7810
+ var CLOSE_PAGE = [
7811
+ '<!doctype html><meta charset="utf-8"><title>Arcane</title>',
7812
+ '<body style="font-family:system-ui;margin:3rem"><h1>Signed in to Arcane</h1>',
7813
+ "<p>You can close this window and go back to the terminal.</p></body>"
7814
+ ].join("");
7815
+ var FAILED_PAGE = [
7816
+ '<!doctype html><meta charset="utf-8"><title>Arcane</title>',
7817
+ '<body style="font-family:system-ui;margin:3rem"><h1>Sign-in not completed</h1>',
7818
+ "<p>Go back to the terminal for what to do next.</p></body>"
7819
+ ].join("");
7820
+ async function browserLogin(config, options) {
7821
+ const codeVerifier = client.randomPKCECodeVerifier();
7822
+ const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
7823
+ const state = client.randomState();
7824
+ const nonce = client.randomNonce();
7825
+ const server = createServer();
7826
+ await new Promise((resolve5, reject) => {
7827
+ server.once("error", reject);
7828
+ server.listen(0, "127.0.0.1", () => resolve5());
7829
+ });
7830
+ const address = server.address();
7831
+ if (address === null || typeof address === "string") {
7832
+ server.close();
7833
+ throw new Error("Could not open a loopback port for the sign-in.");
7834
+ }
7835
+ const redirectUri = new URL(`http://127.0.0.1:${address.port}`).href;
7836
+ const authorizationUrl = client.buildAuthorizationUrl(config, {
7837
+ redirect_uri: redirectUri,
7838
+ scope: options.scopes.join(" "),
7839
+ code_challenge: codeChallenge,
7840
+ code_challenge_method: "S256",
7841
+ state,
7842
+ nonce
7843
+ });
7844
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
7845
+ let timer;
7846
+ const close = () => {
7847
+ if (timer) clearTimeout(timer);
7848
+ server.closeAllConnections?.();
7849
+ server.close();
7850
+ };
7851
+ const tokens = new Promise((resolve5, reject) => {
7852
+ server.on("request", (req, res) => {
7853
+ const url = new URL(req.url ?? "/", redirectUri);
7854
+ if (url.pathname !== "/" || !url.searchParams.has("code") && !url.searchParams.has("error")) {
7855
+ res.writeHead(404).end();
7856
+ return;
7857
+ }
7858
+ const errorCode = url.searchParams.get("error");
7859
+ if (errorCode) {
7860
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAILED_PAGE);
7861
+ reject(new LoginDeniedError(errorCode.replace(/[^a-z_]/gi, "")));
7862
+ return;
7863
+ }
7864
+ client.authorizationCodeGrant(config, url, {
7865
+ pkceCodeVerifier: codeVerifier,
7866
+ expectedState: state,
7867
+ expectedNonce: nonce,
7868
+ idTokenExpected: true
7869
+ }).then((response) => {
7870
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(CLOSE_PAGE);
7871
+ resolve5(toTokenResult(response));
7872
+ }).catch((error) => {
7873
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAILED_PAGE);
7874
+ reject(error);
7875
+ });
7876
+ });
7877
+ timer = setTimeout(() => reject(new LoginTimeoutError()), timeoutMs);
7878
+ });
7879
+ tokens.catch(() => {
7880
+ });
7881
+ try {
7882
+ const opened = await options.openUrl(authorizationUrl.href);
7883
+ if (!opened) options.onCannotOpen?.(authorizationUrl.href);
7884
+ return await tokens;
7885
+ } finally {
7886
+ close();
7887
+ }
7888
+ }
7889
+ async function deviceLogin(config, options) {
7890
+ const handle = await client.initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
7891
+ options.prompt(handle.verification_uri, handle.user_code, handle.expires_in);
7892
+ const response = await client.pollDeviceAuthorizationGrant(config, handle);
7893
+ return toTokenResult(response);
7894
+ }
7895
+ async function refreshSession(config, refreshToken, scopes) {
7896
+ const response = await client.refreshTokenGrant(config, refreshToken, { scope: scopes.join(" ") });
7897
+ return toTokenResult(response, refreshToken);
7898
+ }
7899
+ function openInDefaultBrowser(url) {
7900
+ const [command, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
7901
+ return new Promise((resolve5) => {
7902
+ try {
7903
+ const child = execFile3(command, args, { windowsHide: true }, (error) => resolve5(!error));
7904
+ child.on("error", () => resolve5(false));
7905
+ } catch {
7906
+ resolve5(false);
7907
+ }
7908
+ });
7909
+ }
7910
+
7911
+ // src/modules/session-store.ts
7912
+ import { execFileSync } from "child_process";
7913
+ import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync, existsSync as existsSync2 } from "fs";
7914
+ import { userInfo } from "os";
7915
+ import { dirname as dirname10, join as join17 } from "path";
7916
+
7917
+ // src/modules/keychain-backend.ts
7918
+ import { createRequire as createRequire2 } from "module";
7919
+ var KEYCHAIN_SERVICE = "arcane-cli";
7920
+ function createKeychainBackend(service = KEYCHAIN_SERVICE) {
7921
+ let loaded = null;
7922
+ const keyring = () => {
7923
+ loaded ??= createRequire2(import.meta.url)("@napi-rs/keyring");
7924
+ return loaded;
7925
+ };
7926
+ const entry = (name) => new (keyring()).Entry(service, name);
7927
+ return {
7928
+ get: (name) => entry(name).getPassword() ?? null,
7929
+ set: (name, value) => {
7930
+ entry(name).setPassword(value);
7931
+ },
7932
+ delete: (name) => {
7933
+ entry(name).deletePassword();
7934
+ },
7935
+ list: () => keyring().findCredentials(service).map((credential) => credential.account)
7936
+ };
7937
+ }
7938
+
7939
+ // src/modules/session-store.ts
7940
+ var INSECURE_SESSION_FILE_NAME = "session-insecure.json";
7941
+ function isIsoDate(value) {
7942
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
7943
+ }
7944
+ function parseSessionRecord(raw) {
7945
+ let parsed;
7946
+ try {
7947
+ parsed = JSON.parse(raw);
7948
+ } catch {
7949
+ return null;
7950
+ }
7951
+ if (typeof parsed !== "object" || parsed === null) return null;
7952
+ const o = parsed;
7953
+ if (o["v"] !== 1 || o["environment"] !== "production" && o["environment"] !== "development" || typeof o["sub"] !== "string" || o["sub"] === "" || !(typeof o["email"] === "string" || o["email"] === null) || typeof o["refreshToken"] !== "string" || o["refreshToken"] === "" || !isIsoDate(o["obtainedAt"]) || !isIsoDate(o["expiresAt"])) {
7954
+ return null;
7955
+ }
7956
+ return {
7957
+ v: 1,
7958
+ environment: o["environment"],
7959
+ sub: o["sub"],
7960
+ email: o["email"],
7961
+ refreshToken: o["refreshToken"],
7962
+ obtainedAt: o["obtainedAt"],
7963
+ expiresAt: o["expiresAt"]
7964
+ };
7965
+ }
7966
+ var FileSecretBackend = class {
7967
+ constructor(filePath, restrict = restrictToCurrentUser) {
7968
+ this.filePath = filePath;
7969
+ this.restrict = restrict;
7970
+ }
7971
+ filePath;
7972
+ restrict;
7973
+ exists() {
7974
+ return existsSync2(this.filePath);
7975
+ }
7976
+ read() {
7977
+ if (!existsSync2(this.filePath)) return {};
7978
+ const parsed = JSON.parse(readFileSync2(this.filePath, "utf8"));
7979
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
7980
+ throw new Error("the session file is not an object");
7981
+ }
7982
+ const out = {};
7983
+ for (const [k, v] of Object.entries(parsed)) {
7984
+ if (typeof v === "string") out[k] = v;
7985
+ }
7986
+ return out;
7987
+ }
7988
+ write(entries) {
7989
+ if (Object.keys(entries).length === 0) {
7990
+ rmSync(this.filePath, { force: true });
7991
+ return;
7992
+ }
7993
+ mkdirSync(dirname10(this.filePath), { recursive: true });
7994
+ const tmp = `${this.filePath}.${process.pid}.tmp`;
7995
+ writeFileSync(tmp, JSON.stringify(entries), { encoding: "utf8", mode: 384 });
7996
+ try {
7997
+ this.restrict(tmp);
7998
+ } catch (error) {
7999
+ rmSync(tmp, { force: true });
8000
+ throw new Error(
8001
+ "Could not restrict the session file to your account, so it was not written.",
8002
+ { cause: error }
8003
+ );
8004
+ }
8005
+ renameSync(tmp, this.filePath);
8006
+ }
8007
+ get(name) {
8008
+ return this.read()[name] ?? null;
8009
+ }
8010
+ set(name, value) {
8011
+ const entries = this.read();
8012
+ entries[name] = value;
8013
+ this.write(entries);
8014
+ }
8015
+ delete(name) {
8016
+ const entries = this.read();
8017
+ if (!(name in entries)) return;
8018
+ delete entries[name];
8019
+ this.write(entries);
8020
+ }
8021
+ list() {
8022
+ return Object.keys(this.read());
8023
+ }
8024
+ /** Removes the file itself, strays and all. */
8025
+ remove() {
8026
+ rmSync(this.filePath, { force: true });
8027
+ }
8028
+ };
8029
+ function restrictToCurrentUser(filePath) {
8030
+ if (process.platform !== "win32") return;
8031
+ const account = userInfo().username;
8032
+ execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${account}:F`], {
8033
+ stdio: "ignore",
8034
+ windowsHide: true
8035
+ });
8036
+ }
8037
+ function insecureSessionFilePath(homeDir) {
8038
+ return join17(userTierRoot(homeDir), INSECURE_SESSION_FILE_NAME);
8039
+ }
8040
+ function createSessionStorage(options = {}) {
8041
+ const keychain = new ChunkedSecretStore(options.keychainBackend ?? createKeychainBackend());
8042
+ const fileBackend = options.fileBackend ?? new FileSecretBackend(insecureSessionFilePath());
8043
+ const file = new ChunkedSecretStore(fileBackend);
8044
+ const interpret = (result, source) => {
8045
+ if (result.state === "signed-out") return null;
8046
+ if (result.state === "corrupt") return { state: "corrupt", source, reason: result.reason };
8047
+ const record = parseSessionRecord(result.secret);
8048
+ if (record === null) return { state: "corrupt", source, reason: "the stored session is not valid" };
8049
+ return { state: "ok", source, record };
8050
+ };
8051
+ return {
8052
+ load() {
8053
+ let keychainError;
8054
+ try {
8055
+ const fromKeychain = interpret(keychain.load(), "keychain");
8056
+ if (fromKeychain) return fromKeychain;
8057
+ } catch (error) {
8058
+ if (!(error instanceof KeychainUnavailableError)) throw error;
8059
+ keychainError = error;
8060
+ }
8061
+ if (fileBackend.exists()) {
8062
+ const fromFile = interpret(file.load(), "file");
8063
+ if (fromFile) return fromFile;
8064
+ }
8065
+ return keychainError ? { state: "signed-out", keychainError } : { state: "signed-out" };
8066
+ },
8067
+ save(record, { insecure }) {
8068
+ const serialized = JSON.stringify(record);
8069
+ if (insecure) {
8070
+ file.save(serialized);
8071
+ try {
8072
+ keychain.clear();
8073
+ } catch {
8074
+ }
8075
+ return;
8076
+ }
8077
+ keychain.save(serialized);
8078
+ fileBackend.remove();
8079
+ },
8080
+ clear() {
8081
+ const removed = [];
8082
+ let keychainError;
8083
+ try {
8084
+ if (keychain.load().state !== "signed-out") removed.push("keychain");
8085
+ keychain.clear();
8086
+ } catch (error) {
8087
+ keychainError = error;
8088
+ }
8089
+ if (fileBackend.exists()) {
8090
+ fileBackend.remove();
8091
+ removed.push("file");
8092
+ }
8093
+ if (keychainError) throw keychainError;
8094
+ return { removed };
8095
+ }
8096
+ };
8097
+ }
8098
+
8099
+ // src/commands/login.ts
8100
+ function defaultLoginDeps() {
8101
+ return {
8102
+ storage: createSessionStorage(),
8103
+ environment: () => resolveIdentityEnvironment(),
8104
+ loadConfiguration: (environment) => loadIdentityConfiguration(environment),
8105
+ browserLogin,
8106
+ deviceLogin,
8107
+ openUrl: openInDefaultBrowser,
8108
+ isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY)
8109
+ };
8110
+ }
8111
+ var INSECURE_STORAGE_WARNING = "Insecure storage: the session is kept in a file protected only by your account's file permissions, not in the OS keychain. Run `spell logout` to remove it.";
8112
+ function safeErrorText(error) {
8113
+ const message = error instanceof Error ? error.message : String(error);
8114
+ return message.split("\n")[0].replace(/([?&](?:code|state|id_token|access_token|refresh_token)=)[^&\s]+/gi, "$1[redacted]");
8115
+ }
8116
+ function toSessionRecord(result, environment) {
8117
+ return {
8118
+ v: 1,
8119
+ environment: environment.environment,
8120
+ sub: result.sub,
8121
+ email: result.email,
8122
+ refreshToken: result.refreshToken,
8123
+ obtainedAt: result.obtainedAt.toISOString(),
8124
+ expiresAt: result.expiresAt.toISOString()
8125
+ };
8126
+ }
8127
+ function describeAccount(record) {
8128
+ const who = record.email ? `${record.email} (${record.sub})` : record.sub;
8129
+ return record.environment === "development" ? `${who} [development]` : who;
8130
+ }
8131
+ async function runLogin(options, deps = defaultLoginDeps()) {
8132
+ let environment;
8133
+ try {
8134
+ environment = deps.environment();
8135
+ } catch (error) {
8136
+ if (error instanceof UnknownEnvironmentError) {
8137
+ console.error(error.message);
8138
+ process.exit(1);
8139
+ return;
8140
+ }
8141
+ throw error;
8142
+ }
8143
+ if (!options.deviceCode && !deps.isInteractive()) {
8144
+ console.error(
8145
+ "No interactive terminal, so a browser sign-in cannot be started here. Run `spell login --device-code` and finish the sign-in on another device."
8146
+ );
8147
+ process.exit(1);
8148
+ return;
8149
+ }
8150
+ let config;
8151
+ try {
8152
+ config = await deps.loadConfiguration(environment);
8153
+ } catch (error) {
8154
+ console.error(
8155
+ error instanceof IdentityUnreachableError ? `${error.message} Check your network connection and try again.` : `Could not prepare the sign-in: ${safeErrorText(error)}`
8156
+ );
8157
+ process.exit(1);
8158
+ return;
8159
+ }
8160
+ let result;
8161
+ try {
8162
+ if (options.deviceCode) {
8163
+ result = await deps.deviceLogin(config, {
8164
+ scopes: SIGN_IN_SCOPES,
8165
+ prompt: (uri, code, seconds) => {
8166
+ printInfo(`Open ${uri} on any device and enter the code ${code}.`);
8167
+ printInfo(`Waiting up to ${Math.round(seconds / 60)} minutes for you to finish signing in.`);
8168
+ }
8169
+ });
8170
+ } else {
8171
+ printInfo("Opening your browser to sign in to Arcane. Come back here when it says you are signed in.");
8172
+ result = await deps.browserLogin(config, {
8173
+ scopes: SIGN_IN_SCOPES,
8174
+ openUrl: deps.openUrl,
8175
+ onCannotOpen: (url) => {
8176
+ printWarning("No browser could be opened. Open this address yourself, or run `spell login --device-code`:");
8177
+ printInfo(url);
8178
+ }
8179
+ });
8180
+ }
8181
+ } catch (error) {
8182
+ if (error instanceof LoginTimeoutError || error instanceof LoginDeniedError) {
8183
+ console.error(`${error.message} Nothing was changed.`);
8184
+ } else {
8185
+ console.error(`The sign-in did not complete: ${safeErrorText(error)}. Nothing was changed.`);
8186
+ }
8187
+ process.exit(1);
8188
+ return;
8189
+ }
8190
+ const record = toSessionRecord(result, environment);
8191
+ try {
8192
+ deps.storage.save(record, { insecure: Boolean(options.insecureStorage) });
8193
+ } catch (error) {
8194
+ if (error instanceof KeychainUnavailableError) {
8195
+ console.error(
8196
+ `${error.message} Nothing was stored. If this machine has no keychain, run \`spell login --insecure-storage\` to keep the session in a file instead (protected only by your account's file permissions).`
8197
+ );
8198
+ process.exit(1);
8199
+ return;
8200
+ }
8201
+ console.error(`The session could not be stored: ${safeErrorText(error)}`);
8202
+ process.exit(1);
8203
+ return;
8204
+ }
8205
+ printSuccess(`Signed in as ${describeAccount(record)}.`);
8206
+ if (options.insecureStorage) printWarning(INSECURE_STORAGE_WARNING);
8207
+ }
8208
+
8209
+ // src/commands/logout.ts
8210
+ function runLogout(storage = createSessionStorage()) {
8211
+ let removed;
8212
+ try {
8213
+ removed = storage.clear().removed;
8214
+ } catch (error) {
8215
+ if (error instanceof KeychainUnavailableError) {
8216
+ console.error(`${error.message} The session could not be removed from it.`);
8217
+ process.exit(1);
8218
+ return;
8219
+ }
8220
+ throw error;
8221
+ }
8222
+ if (removed.length === 0) {
8223
+ printSuccess("Nothing was stored; you were already signed out.");
8224
+ return;
8225
+ }
8226
+ const where = removed.map((m) => m === "keychain" ? "the OS keychain" : "the insecure session file").join(" and ");
8227
+ printSuccess(`Signed out: removed the session from ${where}.`);
8228
+ printInfo("This signs out this machine only; the session at the identity service stays valid until it expires.");
8229
+ }
8230
+
8231
+ // src/commands/whoami.ts
8232
+ function defaultWhoamiDeps() {
8233
+ return {
8234
+ storage: createSessionStorage(),
8235
+ loadConfiguration: (environment) => loadIdentityConfiguration(environment),
8236
+ refreshSession,
8237
+ now: () => /* @__PURE__ */ new Date()
8238
+ };
8239
+ }
8240
+ async function runWhoami(options, deps = defaultWhoamiDeps()) {
8241
+ let loaded;
8242
+ try {
8243
+ loaded = deps.storage.load();
8244
+ } catch (error) {
8245
+ if (error instanceof KeychainUnavailableError) {
8246
+ console.error(`${error.message} Cannot tell whether you are signed in.`);
8247
+ process.exit(1);
8248
+ return;
8249
+ }
8250
+ throw error;
8251
+ }
8252
+ if (loaded.state === "signed-out") {
8253
+ console.error("Not signed in. Run `spell login`.");
8254
+ if (loaded.keychainError) console.error(loaded.keychainError.message);
8255
+ process.exit(1);
8256
+ return;
8257
+ }
8258
+ if (loaded.state === "corrupt") {
8259
+ console.error(
8260
+ `The stored session is damaged (${loaded.reason}), so you are treated as signed out. Run \`spell logout\` to clear it, then \`spell login\`.`
8261
+ );
8262
+ process.exit(1);
8263
+ return;
8264
+ }
8265
+ const { record, source } = loaded;
8266
+ printSuccess(`Signed in as ${describeAccount(record)}.`);
8267
+ const expires = new Date(record.expiresAt);
8268
+ const expired = expires.getTime() <= deps.now().getTime();
8269
+ printInfo(`Signed in on ${new Date(record.obtainedAt).toLocaleString()}.`);
8270
+ printInfo(
8271
+ expired ? `The last token expired on ${expires.toLocaleString()}; the session refreshes on next use.` : `The current token is valid until ${expires.toLocaleString()}.`
8272
+ );
8273
+ printInfo(`Stored in ${source === "keychain" ? "the OS keychain" : "the insecure session file"}.`);
8274
+ if (source === "file") printWarning(INSECURE_STORAGE_WARNING);
8275
+ if (!options.verify) return;
8276
+ const environment = IDENTITY_ENVIRONMENTS[record.environment];
8277
+ try {
8278
+ const config = await deps.loadConfiguration(environment);
8279
+ const fresh = await deps.refreshSession(config, record.refreshToken, SIGN_IN_SCOPES);
8280
+ deps.storage.save(toSessionRecord(fresh, environment), { insecure: source === "file" });
8281
+ printSuccess(`Verified with the identity service at ${environment.signInHost}; the session is active.`);
8282
+ } catch (error) {
8283
+ console.error(
8284
+ `The identity service did not accept the session (${safeErrorText(error)}). Run \`spell login\` again.`
8285
+ );
8286
+ process.exit(1);
8287
+ }
8288
+ }
8289
+
7582
8290
  // src/commands/report.ts
7583
8291
  import { access as access5 } from "fs/promises";
7584
- import { join as join19 } from "path";
8292
+ import { join as join20 } from "path";
7585
8293
 
7586
8294
  // src/modules/show-report/generate.ts
7587
- import { mkdir as mkdir8, readdir as readdir11, readFile as readFile16, writeFile as writeFile12 } from "fs/promises";
7588
- import { basename as basename3, dirname as dirname10, isAbsolute, join as join18, relative as relative3, resolve as resolve4 } from "path";
8295
+ import { mkdir as mkdir8, readdir as readdir11, readFile as readFile17, writeFile as writeFile12 } from "fs/promises";
8296
+ import { basename as basename3, dirname as dirname11, isAbsolute, join as join19, relative as relative3, resolve as resolve4 } from "path";
7589
8297
 
7590
8298
  // src/modules/show-report/model.ts
7591
- import { readFile as readFile15 } from "fs/promises";
7592
- import { join as join17 } from "path";
8299
+ import { readFile as readFile16 } from "fs/promises";
8300
+ import { join as join18 } from "path";
7593
8301
 
7594
8302
  // src/modules/show-report/plan-parser.ts
7595
8303
  function parseFrontmatter(content) {
@@ -7862,7 +8570,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
7862
8570
  // src/modules/show-report/model.ts
7863
8571
  async function readOptional(path4) {
7864
8572
  try {
7865
- return await readFile15(path4, "utf8");
8573
+ return await readFile16(path4, "utf8");
7866
8574
  } catch {
7867
8575
  return null;
7868
8576
  }
@@ -7958,13 +8666,13 @@ function buildCorrections(ledgerRows, programName) {
7958
8666
  };
7959
8667
  }
7960
8668
  async function buildShowReportModel(options) {
7961
- const planAbsPath = join17(options.rootDir, options.planRelPath);
7962
- const queueAbsPath = join17(options.rootDir, options.queueRelPath);
8669
+ const planAbsPath = join18(options.rootDir, options.planRelPath);
8670
+ const queueAbsPath = join18(options.rootDir, options.queueRelPath);
7963
8671
  const ledgerRelPath = "docs/verification-ledger.md";
7964
- const ledgerAbsPath = join17(options.rootDir, ledgerRelPath);
7965
- const decisionsAbsPath = join17(options.rootDir, "DECISIONS.md");
8672
+ const ledgerAbsPath = join18(options.rootDir, ledgerRelPath);
8673
+ const decisionsAbsPath = join18(options.rootDir, "DECISIONS.md");
7966
8674
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
7967
- readFile15(planAbsPath, "utf8"),
8675
+ readFile16(planAbsPath, "utf8"),
7968
8676
  readOptional(queueAbsPath),
7969
8677
  readOptional(ledgerAbsPath),
7970
8678
  readOptional(decisionsAbsPath)
@@ -8160,7 +8868,7 @@ function toPosix(path4) {
8160
8868
  async function programFromPlan(rootDir, slug, planRelPath) {
8161
8869
  let planContent;
8162
8870
  try {
8163
- planContent = await readFile16(join18(rootDir, planRelPath), "utf8");
8871
+ planContent = await readFile17(join19(rootDir, planRelPath), "utf8");
8164
8872
  } catch {
8165
8873
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
8166
8874
  }
@@ -8172,13 +8880,13 @@ async function programFromPlan(rootDir, slug, planRelPath) {
8172
8880
  program: {
8173
8881
  slug,
8174
8882
  planRelPath,
8175
- queueRelPath: toPosix(join18(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
8883
+ queueRelPath: toPosix(join19(dirname11(planRelPath), "OPERATOR-QUEUE.md")),
8176
8884
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
8177
8885
  }
8178
8886
  };
8179
8887
  }
8180
8888
  async function discoverPrograms(rootDir) {
8181
- const plansDir = join18(rootDir, "docs", "plans");
8889
+ const plansDir = join19(rootDir, "docs", "plans");
8182
8890
  let entries;
8183
8891
  try {
8184
8892
  entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
@@ -8198,21 +8906,21 @@ async function discoverPrograms(rootDir) {
8198
8906
  async function programFromPlanPath(rootDir, planPath) {
8199
8907
  const planAbs = isAbsolute(planPath) ? planPath : resolve4(rootDir, planPath);
8200
8908
  const planRelPath = toPosix(relative3(rootDir, planAbs));
8201
- return programFromPlan(rootDir, basename3(dirname10(planAbs)), planRelPath);
8909
+ return programFromPlan(rootDir, basename3(dirname11(planAbs)), planRelPath);
8202
8910
  }
8203
8911
  function normalizeLineEndings2(content) {
8204
8912
  return content.replace(/\r\n?/g, "\n");
8205
8913
  }
8206
8914
  async function readOptionalFile(path4) {
8207
8915
  try {
8208
- return await readFile16(path4, "utf8");
8916
+ return await readFile17(path4, "utf8");
8209
8917
  } catch (error) {
8210
8918
  if (error.code === "ENOENT") return null;
8211
8919
  throw error;
8212
8920
  }
8213
8921
  }
8214
8922
  async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
8215
- const planContent = await readFile16(join18(rootDir, program2.planRelPath), "utf8");
8923
+ const planContent = await readFile17(join19(rootDir, program2.planRelPath), "utf8");
8216
8924
  const frontmatter = parseFrontmatter(planContent);
8217
8925
  const model = await buildShowReportModel({
8218
8926
  rootDir,
@@ -8232,7 +8940,7 @@ async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
8232
8940
  };
8233
8941
  }
8234
8942
  async function runReportGeneration(options) {
8235
- const templateHtml = await readFile16(options.templatePath, "utf8");
8943
+ const templateHtml = await readFile17(options.templatePath, "utf8");
8236
8944
  const theme = options.theme ?? "auto";
8237
8945
  const drifted = [];
8238
8946
  const repaired = [];
@@ -8243,19 +8951,19 @@ async function runReportGeneration(options) {
8243
8951
  if (!generated.themeApplied) themeSkipped.push(program2.slug);
8244
8952
  const missing = JSON.parse(generated.json).sections.flatMap((section) => section.rows).filter((row) => row.descriptionState === "unwritten").map((row) => row.id);
8245
8953
  if (missing.length > 0) unwritten.push({ slug: program2.slug, ids: missing });
8246
- const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname10(program2.planRelPath));
8954
+ const outAbs = options.outDir ? isAbsolute(options.outDir) ? options.outDir : resolve4(options.rootDir, options.outDir) : resolve4(options.rootDir, dirname11(program2.planRelPath));
8247
8955
  const outRel = toPosix(relative3(options.rootDir, outAbs));
8248
8956
  const outputs = [
8249
8957
  [`${outRel}/show-report.json`, generated.json],
8250
8958
  [`${outRel}/show-report.html`, generated.html]
8251
8959
  ];
8252
8960
  for (const [relPath, expected] of outputs) {
8253
- const actual = await readOptionalFile(join18(options.rootDir, relPath));
8961
+ const actual = await readOptionalFile(join19(options.rootDir, relPath));
8254
8962
  if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
8255
8963
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
8256
8964
  if (options.mode === "write") {
8257
8965
  await mkdir8(outAbs, { recursive: true });
8258
- await writeFile12(join18(options.rootDir, relPath), expected, "utf8");
8966
+ await writeFile12(join19(options.rootDir, relPath), expected, "utf8");
8259
8967
  repaired.push(relPath);
8260
8968
  }
8261
8969
  }
@@ -8266,7 +8974,7 @@ async function runReportGeneration(options) {
8266
8974
 
8267
8975
  // src/commands/report.ts
8268
8976
  async function runReport(targetDir, options = {}, assetsDir) {
8269
- const templatePath = join19(assetsDir, "report", "show-report.template.html");
8977
+ const templatePath = join20(assetsDir, "report", "show-report.template.html");
8270
8978
  try {
8271
8979
  await access5(templatePath);
8272
8980
  } catch {
@@ -8341,9 +9049,9 @@ async function runReport(targetDir, options = {}, assetsDir) {
8341
9049
 
8342
9050
  // src/index.ts
8343
9051
  import { realpathSync } from "fs";
8344
- var require3 = createRequire2(import.meta.url);
9052
+ var require3 = createRequire3(import.meta.url);
8345
9053
  var pkg = require3("../package.json");
8346
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join20(dirname11(fileURLToPath(import.meta.url)), "assets");
9054
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join21(dirname12(fileURLToPath(import.meta.url)), "assets");
8347
9055
  var program = new Command();
8348
9056
  var USER_FLAG_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 spells shared by every repository on this machine, fanned out to ~/.agents/skills (Codex, Copilot) and ~/.claude/commands (Claude Code)";
8349
9057
  var USER_TIER_AGENTS_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 one agent roster shared by every repository on this machine, rendered to ~/.copilot/agents (VS Code agent modes)";
@@ -8466,6 +9174,18 @@ program.command("block-push").description("Enforce push_policy: blocked on this
8466
9174
  program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
8467
9175
  await runUnblockPush(process.cwd());
8468
9176
  });
9177
+ program.command("login").description("Sign in to your Arcane account (browser by default; the session is kept in the OS keychain)").option("--device-code", "Sign in from another device by typing a code; for machines with no browser").option(
9178
+ "--insecure-storage",
9179
+ "Keep the session in a file under ~/.arcane instead of the OS keychain; protected only by your account's file permissions"
9180
+ ).action(async (opts) => {
9181
+ await runLogin({ deviceCode: opts.deviceCode, insecureStorage: opts.insecureStorage });
9182
+ });
9183
+ program.command("logout").description("Sign out on this machine: remove the stored session").action(() => {
9184
+ runLogout();
9185
+ });
9186
+ program.command("whoami").description("Show who is signed in; exits 1 when signed out").option("--verify", "Also check the session with the identity service").action(async (opts) => {
9187
+ await runWhoami({ verify: opts.verify });
9188
+ });
8469
9189
  program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").option("--leaks", "Run only the on-demand secrets/credential scan (ARC-037), skipping the default checks").action(async (opts) => {
8470
9190
  await runDoctor(
8471
9191
  process.cwd(),