arcane-cli 0.20.1 → 0.20.2

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 createRequire2 } from "module";
8
8
  import { fileURLToPath } from "url";
9
- import { dirname as dirname3, join as join12 } from "path";
9
+ import { dirname as dirname4, join as join12 } from "path";
10
10
 
11
11
  // src/commands/init.ts
12
12
  import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
@@ -1795,9 +1795,9 @@ async function runAgentsList(targetDir) {
1795
1795
  }
1796
1796
 
1797
1797
  // src/modules/push-safety.ts
1798
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2 } from "fs/promises";
1798
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2, readdir as readdir3 } from "fs/promises";
1799
1799
  import { constants } from "fs";
1800
- import { join as join4, resolve } from "path";
1800
+ import { join as join4, resolve, dirname, basename } from "path";
1801
1801
 
1802
1802
  // src/modules/git.ts
1803
1803
  import { execFile } from "child_process";
@@ -2042,11 +2042,11 @@ async function readHooksPath(cwd) {
2042
2042
  const matched = known.find((candidate) => candidate === scope);
2043
2043
  return { value, scope: matched ?? "unknown" };
2044
2044
  }
2045
- function isArcaneHooksPath(cwd, value) {
2045
+ function isArcaneHooksPath(root, value) {
2046
2046
  const windows = process.platform === "win32";
2047
2047
  const normalize = (p) => {
2048
2048
  const separated = windows ? p.replace(/\\/g, "/") : p;
2049
- const absolute = resolve(cwd, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2049
+ const absolute = resolve(root, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2050
2050
  return windows ? absolute.toLowerCase() : absolute;
2051
2051
  };
2052
2052
  return normalize(value) === normalize(ARCANE_HOOKS_DIR);
@@ -2085,34 +2085,81 @@ function describeConfigScope(scope) {
2085
2085
  return "scope unknown";
2086
2086
  }
2087
2087
  }
2088
- function hookFilePath(cwd) {
2089
- return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
2088
+ async function repositoryRoot(cwd) {
2089
+ try {
2090
+ const { stdout } = await runGit(cwd, [
2091
+ "rev-parse",
2092
+ "--path-format=absolute",
2093
+ "--git-common-dir"
2094
+ ]);
2095
+ const commonDir = stdout.trim();
2096
+ if (commonDir !== "") {
2097
+ const parent = dirname(commonDir);
2098
+ if (basename(commonDir) === ".git" && parent !== "") return parent;
2099
+ }
2100
+ } catch {
2101
+ }
2102
+ try {
2103
+ const { stdout } = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
2104
+ const top = stdout.trim();
2105
+ if (top !== "") return top;
2106
+ } catch {
2107
+ }
2108
+ return cwd;
2109
+ }
2110
+ async function hookFilePath(cwd) {
2111
+ return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, "pre-push");
2112
+ }
2113
+ async function hooksPathValue(cwd) {
2114
+ return join4(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
2090
2115
  }
2091
2116
  async function installPrePushHook(cwd) {
2117
+ const root = await repositoryRoot(cwd);
2092
2118
  const existing = await readHooksPath(cwd);
2093
2119
  if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
2094
- if (existing !== void 0 && !isArcaneHooksPath(cwd, existing.value)) {
2120
+ if (existing !== void 0 && !isArcaneHooksPath(root, existing.value)) {
2095
2121
  return {
2096
2122
  status: "refused-foreign-hooks-path",
2097
2123
  existing: existing.value,
2098
2124
  scope: existing.scope
2099
2125
  };
2100
2126
  }
2101
- const hookPath = hookFilePath(cwd);
2127
+ if (existing === void 0) {
2128
+ const displaced = await defaultHooks(cwd);
2129
+ if (displaced.length > 0) {
2130
+ return { status: "refused-default-hooks", hooks: displaced };
2131
+ }
2132
+ }
2133
+ const hookPath = join4(root, ARCANE_HOOKS_DIR, "pre-push");
2102
2134
  if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
2103
- await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
2135
+ await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2104
2136
  await writeFile5(hookPath, HOOK_BODY, "utf-8");
2105
2137
  await chmod(hookPath, 493);
2106
- await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
2138
+ await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2107
2139
  return { status: "installed", path: hookPath };
2108
2140
  }
2109
2141
  async function isHookEnforced(cwd) {
2110
2142
  const hooksPath = await readHooksPath(cwd);
2111
- if (hooksPath === void 0 || !isArcaneHooksPath(cwd, hooksPath.value)) return false;
2143
+ if (hooksPath === void 0 || hooksPath.unreadable === true) return false;
2144
+ if (!isArcaneHooksPath(await repositoryRoot(cwd), hooksPath.value)) return false;
2112
2145
  return hookBodyMatches(cwd);
2113
2146
  }
2147
+ async function defaultHooks(cwd) {
2148
+ let hooksDir;
2149
+ try {
2150
+ const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
2151
+ hooksDir = join4(resolve(cwd, stdout.trim()), "hooks");
2152
+ } catch {
2153
+ return [];
2154
+ }
2155
+ try {
2156
+ return (await readdir3(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
2157
+ } catch {
2158
+ return [];
2159
+ }
2160
+ }
2114
2161
  async function hookBodyMatches(cwd) {
2115
- const hookPath = hookFilePath(cwd);
2162
+ const hookPath = await hookFilePath(cwd);
2116
2163
  if (!await fileExists(hookPath)) return false;
2117
2164
  try {
2118
2165
  if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
@@ -2130,7 +2177,7 @@ async function hookBodyMatches(cwd) {
2130
2177
  async function removePrePushHook(cwd) {
2131
2178
  const existing = await readHooksPath(cwd);
2132
2179
  if (existing === void 0 || existing.unreadable === true) return;
2133
- if (!isArcaneHooksPath(cwd, existing.value)) return;
2180
+ if (!isArcaneHooksPath(await repositoryRoot(cwd), existing.value)) return;
2134
2181
  if (existing.scope !== "local" && existing.scope !== "worktree") return;
2135
2182
  const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2136
2183
  try {
@@ -2138,7 +2185,7 @@ async function removePrePushHook(cwd) {
2138
2185
  } catch {
2139
2186
  }
2140
2187
  try {
2141
- await rm2(hookFilePath(cwd), { force: true });
2188
+ await rm2(await hookFilePath(cwd), { force: true });
2142
2189
  } catch {
2143
2190
  }
2144
2191
  }
@@ -2183,11 +2230,11 @@ async function readScopedAll(cwd, key) {
2183
2230
  } catch {
2184
2231
  return [];
2185
2232
  }
2186
- return stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean).map((line) => {
2233
+ return stdout.split("\n").filter((line) => line !== "").map((line) => {
2187
2234
  const separator = line.indexOf(" ");
2188
2235
  if (separator === -1) return { scope: "unknown", value: line.trim() };
2189
2236
  return { scope: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
2190
- }).filter((entry) => entry.value !== "");
2237
+ });
2191
2238
  }
2192
2239
  async function effectivePushUrls(cwd, remote) {
2193
2240
  const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
@@ -2206,13 +2253,18 @@ async function disablePushUrls(cwd) {
2206
2253
  }
2207
2254
  async function disableOneRemote(cwd, remote) {
2208
2255
  const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
2209
- const foreign = scoped.filter((entry) => entry.scope !== "local" && entry.value !== DISABLED_PUSH_URL);
2256
+ const OVERRIDABLE = /* @__PURE__ */ new Set(["local", "worktree"]);
2257
+ const foreign = scoped.filter(
2258
+ (entry) => !OVERRIDABLE.has(entry.scope) && entry.value !== DISABLED_PUSH_URL
2259
+ );
2210
2260
  if (foreign.length > 0) {
2211
- const where = [...new Set(foreign.map((entry) => entry.scope))].join(", ");
2261
+ const scopes = [...new Set(foreign.map((entry) => entry.scope))];
2262
+ const where = scopes.join(", ");
2263
+ const flag = scopes[0] === "unknown" ? "<scope>" : `--${scopes[0]}`;
2212
2264
  return {
2213
2265
  remote,
2214
2266
  status: "failed",
2215
- reason: `a push URL for this remote is configured at ${where} scope, which this repository cannot override \u2014 git would still deliver the push there. Remove it with \`git config --${where.split(", ")[0]} --unset-all remote.${remote}.pushurl\` and re-apply.`
2267
+ reason: `a push URL for this remote is configured at ${where} scope, which this repository cannot override \u2014 git would still deliver the push there. Remove it with \`git config ${flag} --unset-all remote.${remote}.pushurl\` and re-apply.`
2216
2268
  };
2217
2269
  }
2218
2270
  const currentPushUrls = scoped.map((entry) => entry.value);
@@ -2237,6 +2289,15 @@ async function disableOneRemote(cwd, remote) {
2237
2289
  `remote.${remote}.pushurl`,
2238
2290
  DISABLED_PUSH_URL
2239
2291
  ]);
2292
+ const after = await effectivePushUrls(cwd, remote);
2293
+ if (after.length !== 1 || after[0] !== DISABLED_PUSH_URL) {
2294
+ const live = after.filter((url) => url !== DISABLED_PUSH_URL);
2295
+ return {
2296
+ remote,
2297
+ status: "failed",
2298
+ reason: `the sentinel was written but git still resolves a live push URL for this remote (${live.join(", ")}). It most likely comes from a file this repository \`include\`s, which \`--replace-all\` cannot remove. Run \`git config --show-origin --get-all remote.${remote}.pushurl\` to find the file, and clear it there.`
2299
+ };
2300
+ }
2240
2301
  return { remote, status: "disabled" };
2241
2302
  }
2242
2303
  async function undisabledRemotes(cwd) {
@@ -2277,7 +2338,7 @@ async function restoreOneRemote(cwd, remote) {
2277
2338
  hadNone = false;
2278
2339
  }
2279
2340
  if (hadNone) {
2280
- await runGit(cwd, ["config", "--local", "--unset-all", `remote.${remote}.pushurl`]);
2341
+ await unsetIgnoringMissing(cwd, `remote.${remote}.pushurl`);
2281
2342
  } else {
2282
2343
  await runGit(cwd, [
2283
2344
  "config",
@@ -2292,12 +2353,19 @@ async function restoreOneRemote(cwd, remote) {
2292
2353
  }
2293
2354
  for (const staleKey of [key, NO_PUSH_URL_MARKER(remote), `arcane.originalPushUrl.${remote}`]) {
2294
2355
  try {
2295
- await runGit(cwd, ["config", "--local", "--unset-all", staleKey]);
2356
+ await unsetIgnoringMissing(cwd, staleKey);
2296
2357
  } catch {
2297
2358
  }
2298
2359
  }
2299
2360
  return { remote, status: "restored" };
2300
2361
  }
2362
+ async function unsetIgnoringMissing(cwd, key) {
2363
+ try {
2364
+ await runGit(cwd, ["config", "--local", "--unset-all", key]);
2365
+ } catch (error) {
2366
+ if (error.code !== 5) throw error;
2367
+ }
2368
+ }
2301
2369
  async function hadNoPushUrl(cwd, remote) {
2302
2370
  return (await readLocalAll(cwd, NO_PUSH_URL_MARKER(remote)))[0] === "true";
2303
2371
  }
@@ -2659,6 +2727,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2659
2727
  printWarning(
2660
2728
  "Did not install the pre-push hook: git could not report the current core.hooksPath (unreadable config, or a git too old for `--show-scope`). Installing without knowing whether another hook manager owns that setting could silently disable it. Fix the config and run `spell doctor`."
2661
2729
  );
2730
+ } else if (hook.status === "refused-default-hooks") {
2731
+ printWarning(
2732
+ `Did not install the pre-push hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run, or add the push guard to your existing pre-push hook.`
2733
+ );
2662
2734
  } else if (hook.status === "refused-foreign-hooks-path") {
2663
2735
  printWarning(
2664
2736
  `Did not install the pre-push hook: core.hooksPath is already "${hook.existing}" (${describeConfigScope(hook.scope)}), so another hook manager owns it. Overwriting would silently disable those hooks. Chain an Arcane pre-push guard into that directory yourself, or unset core.hooksPath first.`
@@ -2819,7 +2891,7 @@ import { join as join8 } from "path";
2819
2891
 
2820
2892
  // src/modules/hub.ts
2821
2893
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
2822
- import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
2894
+ import { readdir as readdir4, writeFile as writeFile6 } from "fs/promises";
2823
2895
  import { join as join7 } from "path";
2824
2896
  var MANIFEST_RETROFITS = [
2825
2897
  {
@@ -2963,7 +3035,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
2963
3035
  const businessRootPath = join7(targetDir, businessRoot);
2964
3036
  let ventureDirs;
2965
3037
  try {
2966
- const entries = await readdir3(businessRootPath, { withFileTypes: true });
3038
+ const entries = await readdir4(businessRootPath, { withFileTypes: true });
2967
3039
  ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
2968
3040
  } catch {
2969
3041
  return;
@@ -3170,7 +3242,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
3170
3242
 
3171
3243
  // src/modules/version-check.ts
3172
3244
  import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
3173
- import { dirname, join as join9 } from "path";
3245
+ import { dirname as dirname2, join as join9 } from "path";
3174
3246
  import { homedir as homedir2 } from "os";
3175
3247
  import { createRequire } from "module";
3176
3248
  var require2 = createRequire(import.meta.url);
@@ -3199,7 +3271,7 @@ async function readCache(cachePath) {
3199
3271
  }
3200
3272
  }
3201
3273
  async function writeCache(cachePath, latest) {
3202
- await mkdir5(dirname(cachePath), { recursive: true });
3274
+ await mkdir5(dirname2(cachePath), { recursive: true });
3203
3275
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3204
3276
  await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3205
3277
  }
@@ -3409,7 +3481,7 @@ Run \`spell unblock-push\` first (interactive), then uninstall.`
3409
3481
 
3410
3482
  // src/commands/doctor.ts
3411
3483
  import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3412
- import { join as join11, dirname as dirname2 } from "path";
3484
+ import { join as join11, dirname as dirname3 } from "path";
3413
3485
  import { execFile as execFile2 } from "child_process";
3414
3486
  import { promisify } from "util";
3415
3487
 
@@ -3502,8 +3574,8 @@ async function checkVSCodeExtension(id, label) {
3502
3574
  ];
3503
3575
  for (const dir of extDirs) {
3504
3576
  try {
3505
- const { readdir: readdir4 } = await import("fs/promises");
3506
- const entries = await readdir4(dir);
3577
+ const { readdir: readdir5 } = await import("fs/promises");
3578
+ const entries = await readdir5(dir);
3507
3579
  const prefix = id.toLowerCase() + "-";
3508
3580
  const found = entries.some(
3509
3581
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -3651,7 +3723,7 @@ async function fixSessionContinuity(targetDir, assetsDir) {
3651
3723
  await access4(destPath);
3652
3724
  } catch {
3653
3725
  const srcPath = join11(assetsDir, file);
3654
- await mkdir6(dirname2(destPath), { recursive: true });
3726
+ await mkdir6(dirname3(destPath), { recursive: true });
3655
3727
  await fsCopyFile2(srcPath, destPath);
3656
3728
  created.push(file);
3657
3729
  }
@@ -3762,7 +3834,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3762
3834
  }
3763
3835
 
3764
3836
  // src/commands/unblock-push.ts
3765
- import { basename, resolve as resolve2 } from "path";
3837
+ import { basename as basename2, resolve as resolve2 } from "path";
3766
3838
  import { input as input4 } from "@inquirer/prompts";
3767
3839
  async function runUnblockPush(targetDir) {
3768
3840
  let manifest;
@@ -3777,10 +3849,16 @@ async function runUnblockPush(targetDir) {
3777
3849
  throw err;
3778
3850
  }
3779
3851
  const policy = manifest.push_policy ?? "open";
3780
- if (policy === "open") {
3852
+ const stillInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
3853
+ if (policy === "open" && !stillInForce) {
3781
3854
  printInfo("This repository is not push-blocked \u2014 nothing to undo.");
3782
3855
  return;
3783
3856
  }
3857
+ if (policy === "open") {
3858
+ printWarning(
3859
+ "The manifest says push_policy: open, but this repository still has Arcane's push block in force \u2014 most likely a previous unblock only partly succeeded. Continuing so it can be finished."
3860
+ );
3861
+ }
3784
3862
  if (!process.stdin.isTTY) {
3785
3863
  console.error(
3786
3864
  "spell unblock-push must be run from an interactive terminal.\nLifting a push block is a deliberate decision, so it cannot be scripted, piped, or run in CI."
@@ -3788,7 +3866,7 @@ async function runUnblockPush(targetDir) {
3788
3866
  process.exit(1);
3789
3867
  return;
3790
3868
  }
3791
- const repoName = basename(resolve2(targetDir));
3869
+ const repoName = basename2(resolve2(targetDir));
3792
3870
  if (repoName === "") {
3793
3871
  console.error(
3794
3872
  "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
@@ -3857,7 +3935,7 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
3857
3935
  import { realpathSync } from "fs";
3858
3936
  var require3 = createRequire2(import.meta.url);
3859
3937
  var pkg = require3("../package.json");
3860
- var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname3(fileURLToPath(import.meta.url)), "assets");
3938
+ var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname4(fileURLToPath(import.meta.url)), "assets");
3861
3939
  var program = new Command();
3862
3940
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
3863
3941
  await printWelcome(pkg.version, process.cwd());