arcane-cli 1.11.5 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ const require = __createRequire(import.meta.url);
6
6
  import { Command } from "commander";
7
7
  import { createRequire as createRequire3 } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { dirname as dirname12, join as join21 } from "path";
9
+ import { dirname as dirname12, join as join22 } 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";
@@ -7756,6 +7756,70 @@ function resolveIdentityEnvironment(env = process.env) {
7756
7756
  import { createServer } from "http";
7757
7757
  import { execFile as execFile3 } from "child_process";
7758
7758
  import * as client from "openid-client";
7759
+
7760
+ // src/modules/result-pages.ts
7761
+ import { createHash as createHash3 } from "crypto";
7762
+ import { readFile as readFile16 } from "fs/promises";
7763
+ import { join as join17 } from "path";
7764
+ var RESULT_KINDS = ["success", "denied", "failure"];
7765
+ function resultPagePath(kind) {
7766
+ return join17("sign-in", `sign-in-callback-${kind}.html`);
7767
+ }
7768
+ function styleHashes(html) {
7769
+ return [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)].map((match) => {
7770
+ const text = match[1].replace(/\r\n?/g, "\n");
7771
+ return `'sha256-${createHash3("sha256").update(text, "utf8").digest("base64")}'`;
7772
+ });
7773
+ }
7774
+ function resultPageHeaders(html) {
7775
+ const hashes = [...new Set(styleHashes(html))];
7776
+ return {
7777
+ "cache-control": "no-store",
7778
+ "content-security-policy": [
7779
+ "default-src 'none'",
7780
+ `style-src ${hashes.length > 0 ? hashes.join(" ") : "'none'"}`,
7781
+ "img-src data:",
7782
+ "font-src data:",
7783
+ "base-uri 'none'",
7784
+ "form-action 'none'",
7785
+ "frame-ancestors 'none'"
7786
+ ].join("; "),
7787
+ "content-type": "text/html; charset=utf-8",
7788
+ "referrer-policy": "no-referrer",
7789
+ "x-content-type-options": "nosniff"
7790
+ };
7791
+ }
7792
+ function resultPage(html) {
7793
+ return { html, headers: resultPageHeaders(html) };
7794
+ }
7795
+ function plainPage(heading, next) {
7796
+ return resultPage(
7797
+ [
7798
+ '<!doctype html><html lang="en"><meta charset="utf-8">',
7799
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
7800
+ `<title>${heading} \u2014 Arcane</title>`,
7801
+ "<style>:root{color-scheme:light dark}body{font-family:system-ui,sans-serif;margin:3rem;line-height:1.5}</style>",
7802
+ `<body><h1>${heading}</h1><p>${next}</p></body></html>`
7803
+ ].join("")
7804
+ );
7805
+ }
7806
+ var PLAIN_RESULT_PAGES = {
7807
+ success: plainPage("Signed in to Arcane", "Return to your terminal. Your browser can be closed now."),
7808
+ denied: plainPage("Sign-in was not completed", "Return to your terminal to try again. No new session was stored."),
7809
+ failure: plainPage("Could not finish sign-in", "Return to your terminal for details. No new session was stored.")
7810
+ };
7811
+ async function loadResultPages(assetsDir) {
7812
+ try {
7813
+ const [success, denied, failure] = await Promise.all(
7814
+ RESULT_KINDS.map(async (kind) => resultPage(await readFile16(join17(assetsDir, resultPagePath(kind)), "utf8")))
7815
+ );
7816
+ return { success, denied, failure };
7817
+ } catch {
7818
+ return null;
7819
+ }
7820
+ }
7821
+
7822
+ // src/modules/oidc.ts
7759
7823
  var IdentityUnreachableError = class extends Error {
7760
7824
  constructor(host, cause) {
7761
7825
  super(`Could not reach the identity service at ${host}.`);
@@ -7769,6 +7833,12 @@ var LoginTimeoutError = class extends Error {
7769
7833
  this.name = "LoginTimeoutError";
7770
7834
  }
7771
7835
  };
7836
+ var LoginBrowserUnavailableError = class extends Error {
7837
+ constructor() {
7838
+ super("No browser could be opened. Run `spell login --device-code` and finish signing in on another device.");
7839
+ this.name = "LoginBrowserUnavailableError";
7840
+ }
7841
+ };
7772
7842
  var LoginDeniedError = class extends Error {
7773
7843
  constructor(code) {
7774
7844
  super(`The identity service refused the sign-in (${code}).`);
@@ -7811,16 +7881,8 @@ function toTokenResult(tokens, previousRefreshToken) {
7811
7881
  expiresAt: new Date(now.getTime() + seconds * 1e3)
7812
7882
  };
7813
7883
  }
7814
- var CLOSE_PAGE = [
7815
- '<!doctype html><meta charset="utf-8"><title>Arcane</title>',
7816
- '<body style="font-family:system-ui;margin:3rem"><h1>Signed in to Arcane</h1>',
7817
- "<p>You can close this window and go back to the terminal.</p></body>"
7818
- ].join("");
7819
- var FAILED_PAGE = [
7820
- '<!doctype html><meta charset="utf-8"><title>Arcane</title>',
7821
- '<body style="font-family:system-ui;margin:3rem"><h1>Sign-in not completed</h1>',
7822
- "<p>Go back to the terminal for what to do next.</p></body>"
7823
- ].join("");
7884
+ var resultPath = (kind) => `/result/${kind}`;
7885
+ var RESULT_PATHS = new Set(RESULT_KINDS.map(resultPath));
7824
7886
  async function browserLogin(config, options) {
7825
7887
  const codeVerifier = client.randomPKCECodeVerifier();
7826
7888
  const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
@@ -7845,8 +7907,11 @@ async function browserLogin(config, options) {
7845
7907
  state,
7846
7908
  nonce
7847
7909
  });
7910
+ const pages = options.pages ?? PLAIN_RESULT_PAGES;
7848
7911
  const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3;
7849
7912
  let timer;
7913
+ let callbackConsumed = false;
7914
+ let browserResult;
7850
7915
  const close = () => {
7851
7916
  if (timer) clearTimeout(timer);
7852
7917
  server.closeAllConnections?.();
@@ -7855,14 +7920,53 @@ async function browserLogin(config, options) {
7855
7920
  const tokens = new Promise((resolve5, reject) => {
7856
7921
  server.on("request", (req, res) => {
7857
7922
  const url = new URL(req.url ?? "/", redirectUri);
7923
+ if (RESULT_PATHS.has(url.pathname)) {
7924
+ if (!browserResult || url.pathname !== resultPath(browserResult.kind) || url.search !== "") {
7925
+ res.writeHead(404).end();
7926
+ return;
7927
+ }
7928
+ const served = browserResult;
7929
+ const page = pages[served.kind];
7930
+ const settle = () => {
7931
+ if (served.kind === "success") resolve5(served.result);
7932
+ else reject(served.error);
7933
+ };
7934
+ res.once("finish", settle).once("close", settle);
7935
+ res.writeHead(200, page.headers).end(page.html);
7936
+ return;
7937
+ }
7858
7938
  if (url.pathname !== "/" || !url.searchParams.has("code") && !url.searchParams.has("error")) {
7859
7939
  res.writeHead(404).end();
7860
7940
  return;
7861
7941
  }
7942
+ if (callbackConsumed) {
7943
+ res.writeHead(409, { "cache-control": "no-store" }).end();
7944
+ return;
7945
+ }
7946
+ if (url.searchParams.get("state") !== state) {
7947
+ res.writeHead(400, pages.failure.headers).end(pages.failure.html);
7948
+ return;
7949
+ }
7950
+ callbackConsumed = true;
7951
+ const redirectToResult = (result) => {
7952
+ browserResult = result;
7953
+ res.writeHead(303, {
7954
+ "cache-control": "no-store",
7955
+ location: resultPath(result.kind),
7956
+ "referrer-policy": "no-referrer"
7957
+ }).end();
7958
+ if (result.kind === "success") {
7959
+ if (timer) clearTimeout(timer);
7960
+ timer = setTimeout(() => resolve5(result.result), Math.min(timeoutMs, 5e3));
7961
+ }
7962
+ };
7862
7963
  const errorCode = url.searchParams.get("error");
7863
7964
  if (errorCode) {
7864
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAILED_PAGE);
7865
- reject(new LoginDeniedError(errorCode.replace(/[^a-z_]/gi, "")));
7965
+ const code = errorCode.replace(/[^a-z_]/gi, "");
7966
+ redirectToResult({
7967
+ kind: code === "access_denied" ? "denied" : "failure",
7968
+ error: new LoginDeniedError(code)
7969
+ });
7866
7970
  return;
7867
7971
  }
7868
7972
  client.authorizationCodeGrant(config, url, {
@@ -7873,11 +7977,9 @@ async function browserLogin(config, options) {
7873
7977
  }).then(async (response) => {
7874
7978
  const result = toTokenResult(response);
7875
7979
  await options.onValidated?.(result);
7876
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(CLOSE_PAGE);
7877
- resolve5(result);
7980
+ redirectToResult({ kind: "success", result });
7878
7981
  }).catch((error) => {
7879
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(FAILED_PAGE);
7880
- reject(error);
7982
+ redirectToResult({ kind: "failure", error });
7881
7983
  });
7882
7984
  });
7883
7985
  timer = setTimeout(() => reject(new LoginTimeoutError()), timeoutMs);
@@ -7886,7 +7988,10 @@ async function browserLogin(config, options) {
7886
7988
  });
7887
7989
  try {
7888
7990
  const opened = await options.openUrl(authorizationUrl.href);
7889
- if (!opened) options.onCannotOpen?.(authorizationUrl.href);
7991
+ if (!opened) {
7992
+ options.onCannotOpen?.();
7993
+ throw new LoginBrowserUnavailableError();
7994
+ }
7890
7995
  return await tokens;
7891
7996
  } finally {
7892
7997
  close();
@@ -7918,7 +8023,7 @@ function openInDefaultBrowser(url) {
7918
8023
  import { execFileSync } from "child_process";
7919
8024
  import { mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, writeFileSync, existsSync as existsSync2 } from "fs";
7920
8025
  import { userInfo } from "os";
7921
- import { dirname as dirname10, join as join17 } from "path";
8026
+ import { dirname as dirname10, join as join18 } from "path";
7922
8027
 
7923
8028
  // src/modules/keychain-backend.ts
7924
8029
  import { createRequire as createRequire2 } from "module";
@@ -8052,7 +8157,7 @@ function restrictToCurrentUser(filePath) {
8052
8157
  });
8053
8158
  }
8054
8159
  function insecureSessionFilePath(homeDir) {
8055
- return join17(userTierRoot(homeDir), INSECURE_SESSION_FILE_NAME);
8160
+ return join18(userTierRoot(homeDir), INSECURE_SESSION_FILE_NAME);
8056
8161
  }
8057
8162
  function createSessionStorage(options = {}) {
8058
8163
  const keychain = new ChunkedSecretStore(options.keychainBackend ?? createKeychainBackend());
@@ -8118,7 +8223,7 @@ function createSessionStorage(options = {}) {
8118
8223
  }
8119
8224
 
8120
8225
  // src/commands/login.ts
8121
- function defaultLoginDeps() {
8226
+ function defaultLoginDeps(assetsDir) {
8122
8227
  return {
8123
8228
  storage: createSessionStorage(),
8124
8229
  environment: () => resolveIdentityEnvironment(),
@@ -8126,10 +8231,12 @@ function defaultLoginDeps() {
8126
8231
  browserLogin,
8127
8232
  deviceLogin,
8128
8233
  openUrl: openInDefaultBrowser,
8129
- isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY)
8234
+ isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
8235
+ resultPages: () => loadResultPages(assetsDir)
8130
8236
  };
8131
8237
  }
8132
8238
  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.";
8239
+ var MISSING_RESULT_PAGES_WARNING = "This arcane-cli install is missing its sign-in result pages (assets/sign-in/), so the browser will show a plain page. Reinstall the package to restore them.";
8133
8240
  function safeErrorText(error) {
8134
8241
  const message = error instanceof Error ? error.message : String(error);
8135
8242
  return message.split("\n")[0].replace(/([?&](?:code|state|id_token|access_token|refresh_token)=)[^&\s]+/gi, "$1[redacted]");
@@ -8149,7 +8256,7 @@ function describeAccount(record) {
8149
8256
  const who = record.email ? `${record.email} (${record.sub})` : record.sub;
8150
8257
  return record.environment === "development" ? `${who} [development]` : who;
8151
8258
  }
8152
- async function runLogin(options, deps = defaultLoginDeps()) {
8259
+ async function runLogin(options, deps) {
8153
8260
  let environment;
8154
8261
  try {
8155
8262
  environment = deps.environment();
@@ -8195,14 +8302,16 @@ async function runLogin(options, deps = defaultLoginDeps()) {
8195
8302
  });
8196
8303
  saveValidatedSession(result);
8197
8304
  } else {
8305
+ const pages = await deps.resultPages();
8306
+ if (!pages) printWarning(MISSING_RESULT_PAGES_WARNING);
8198
8307
  printInfo("Opening your browser to sign in to Arcane. Come back here when it says you are signed in.");
8199
8308
  await deps.browserLogin(config, {
8200
8309
  scopes: SIGN_IN_SCOPES,
8201
8310
  openUrl: deps.openUrl,
8311
+ pages: pages ?? void 0,
8202
8312
  onValidated: saveValidatedSession,
8203
- onCannotOpen: (url) => {
8204
- printWarning("No browser could be opened. Open this address yourself, or run `spell login --device-code`:");
8205
- printInfo(url);
8313
+ onCannotOpen: () => {
8314
+ printWarning("No browser could be opened. Run `spell login --device-code` to sign in on another device.");
8206
8315
  }
8207
8316
  });
8208
8317
  if (!record) throw new Error("The browser sign-in ended without storing a session.");
@@ -8316,15 +8425,15 @@ async function runWhoami(options, deps = defaultWhoamiDeps()) {
8316
8425
 
8317
8426
  // src/commands/report.ts
8318
8427
  import { access as access5 } from "fs/promises";
8319
- import { join as join20 } from "path";
8428
+ import { join as join21 } from "path";
8320
8429
 
8321
8430
  // src/modules/show-report/generate.ts
8322
- import { mkdir as mkdir8, readdir as readdir11, readFile as readFile17, writeFile as writeFile12 } from "fs/promises";
8323
- import { basename as basename3, dirname as dirname11, isAbsolute, join as join19, relative as relative3, resolve as resolve4 } from "path";
8431
+ import { mkdir as mkdir8, readdir as readdir11, readFile as readFile18, writeFile as writeFile12 } from "fs/promises";
8432
+ import { basename as basename3, dirname as dirname11, isAbsolute, join as join20, relative as relative3, resolve as resolve4 } from "path";
8324
8433
 
8325
8434
  // src/modules/show-report/model.ts
8326
- import { readFile as readFile16 } from "fs/promises";
8327
- import { join as join18 } from "path";
8435
+ import { readFile as readFile17 } from "fs/promises";
8436
+ import { join as join19 } from "path";
8328
8437
 
8329
8438
  // src/modules/show-report/plan-parser.ts
8330
8439
  function parseFrontmatter(content) {
@@ -8597,7 +8706,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
8597
8706
  // src/modules/show-report/model.ts
8598
8707
  async function readOptional(path4) {
8599
8708
  try {
8600
- return await readFile16(path4, "utf8");
8709
+ return await readFile17(path4, "utf8");
8601
8710
  } catch {
8602
8711
  return null;
8603
8712
  }
@@ -8693,13 +8802,13 @@ function buildCorrections(ledgerRows, programName) {
8693
8802
  };
8694
8803
  }
8695
8804
  async function buildShowReportModel(options) {
8696
- const planAbsPath = join18(options.rootDir, options.planRelPath);
8697
- const queueAbsPath = join18(options.rootDir, options.queueRelPath);
8805
+ const planAbsPath = join19(options.rootDir, options.planRelPath);
8806
+ const queueAbsPath = join19(options.rootDir, options.queueRelPath);
8698
8807
  const ledgerRelPath = "docs/verification-ledger.md";
8699
- const ledgerAbsPath = join18(options.rootDir, ledgerRelPath);
8700
- const decisionsAbsPath = join18(options.rootDir, "DECISIONS.md");
8808
+ const ledgerAbsPath = join19(options.rootDir, ledgerRelPath);
8809
+ const decisionsAbsPath = join19(options.rootDir, "DECISIONS.md");
8701
8810
  const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
8702
- readFile16(planAbsPath, "utf8"),
8811
+ readFile17(planAbsPath, "utf8"),
8703
8812
  readOptional(queueAbsPath),
8704
8813
  readOptional(ledgerAbsPath),
8705
8814
  readOptional(decisionsAbsPath)
@@ -8895,7 +9004,7 @@ function toPosix(path4) {
8895
9004
  async function programFromPlan(rootDir, slug, planRelPath) {
8896
9005
  let planContent;
8897
9006
  try {
8898
- planContent = await readFile17(join19(rootDir, planRelPath), "utf8");
9007
+ planContent = await readFile18(join20(rootDir, planRelPath), "utf8");
8899
9008
  } catch {
8900
9009
  return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
8901
9010
  }
@@ -8907,13 +9016,13 @@ async function programFromPlan(rootDir, slug, planRelPath) {
8907
9016
  program: {
8908
9017
  slug,
8909
9018
  planRelPath,
8910
- queueRelPath: toPosix(join19(dirname11(planRelPath), "OPERATOR-QUEUE.md")),
9019
+ queueRelPath: toPosix(join20(dirname11(planRelPath), "OPERATOR-QUEUE.md")),
8911
9020
  ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
8912
9021
  }
8913
9022
  };
8914
9023
  }
8915
9024
  async function discoverPrograms(rootDir) {
8916
- const plansDir = join19(rootDir, "docs", "plans");
9025
+ const plansDir = join20(rootDir, "docs", "plans");
8917
9026
  let entries;
8918
9027
  try {
8919
9028
  entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
@@ -8940,14 +9049,14 @@ function normalizeLineEndings2(content) {
8940
9049
  }
8941
9050
  async function readOptionalFile(path4) {
8942
9051
  try {
8943
- return await readFile17(path4, "utf8");
9052
+ return await readFile18(path4, "utf8");
8944
9053
  } catch (error) {
8945
9054
  if (error.code === "ENOENT") return null;
8946
9055
  throw error;
8947
9056
  }
8948
9057
  }
8949
9058
  async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
8950
- const planContent = await readFile17(join19(rootDir, program2.planRelPath), "utf8");
9059
+ const planContent = await readFile18(join20(rootDir, program2.planRelPath), "utf8");
8951
9060
  const frontmatter = parseFrontmatter(planContent);
8952
9061
  const model = await buildShowReportModel({
8953
9062
  rootDir,
@@ -8967,7 +9076,7 @@ async function generateReport(rootDir, program2, templateHtml, theme = "auto") {
8967
9076
  };
8968
9077
  }
8969
9078
  async function runReportGeneration(options) {
8970
- const templateHtml = await readFile17(options.templatePath, "utf8");
9079
+ const templateHtml = await readFile18(options.templatePath, "utf8");
8971
9080
  const theme = options.theme ?? "auto";
8972
9081
  const drifted = [];
8973
9082
  const repaired = [];
@@ -8985,12 +9094,12 @@ async function runReportGeneration(options) {
8985
9094
  [`${outRel}/show-report.html`, generated.html]
8986
9095
  ];
8987
9096
  for (const [relPath, expected] of outputs) {
8988
- const actual = await readOptionalFile(join19(options.rootDir, relPath));
9097
+ const actual = await readOptionalFile(join20(options.rootDir, relPath));
8989
9098
  if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
8990
9099
  drifted.push(`${relPath} does not match a regeneration from its sources.`);
8991
9100
  if (options.mode === "write") {
8992
9101
  await mkdir8(outAbs, { recursive: true });
8993
- await writeFile12(join19(options.rootDir, relPath), expected, "utf8");
9102
+ await writeFile12(join20(options.rootDir, relPath), expected, "utf8");
8994
9103
  repaired.push(relPath);
8995
9104
  }
8996
9105
  }
@@ -9001,7 +9110,7 @@ async function runReportGeneration(options) {
9001
9110
 
9002
9111
  // src/commands/report.ts
9003
9112
  async function runReport(targetDir, options = {}, assetsDir) {
9004
- const templatePath = join20(assetsDir, "report", "show-report.template.html");
9113
+ const templatePath = join21(assetsDir, "report", "show-report.template.html");
9005
9114
  try {
9006
9115
  await access5(templatePath);
9007
9116
  } catch {
@@ -9078,7 +9187,7 @@ async function runReport(targetDir, options = {}, assetsDir) {
9078
9187
  import { realpathSync } from "fs";
9079
9188
  var require3 = createRequire3(import.meta.url);
9080
9189
  var pkg = require3("../package.json");
9081
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join21(dirname12(fileURLToPath(import.meta.url)), "assets");
9190
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join22(dirname12(fileURLToPath(import.meta.url)), "assets");
9082
9191
  var program = new Command();
9083
9192
  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)";
9084
9193
  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)";
@@ -9205,7 +9314,7 @@ program.command("login").description("Sign in to your Arcane account (browser by
9205
9314
  "--insecure-storage",
9206
9315
  "Keep the session in a file under ~/.arcane instead of the OS keychain; protected only by your account's file permissions"
9207
9316
  ).action(async (opts) => {
9208
- await runLogin({ deviceCode: opts.deviceCode, insecureStorage: opts.insecureStorage });
9317
+ await runLogin({ deviceCode: opts.deviceCode, insecureStorage: opts.insecureStorage }, defaultLoginDeps(ASSETS_DIR));
9209
9318
  });
9210
9319
  program.command("logout").description("Sign out on this machine: remove the stored session").action(() => {
9211
9320
  runLogout();