arcane-cli 0.20.0 → 0.20.1

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
@@ -672,7 +672,7 @@ var WORDMARK = [
672
672
  var INDENT = " ";
673
673
  var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
674
674
  function sleep(ms) {
675
- return new Promise((resolve2) => setTimeout(resolve2, ms));
675
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
676
676
  }
677
677
  function isAnimatable() {
678
678
  return !!process.stdout.isTTY && process.env["TERM"] !== "dumb" && process.env["CI"] !== "true" && process.env["ARCANE_DISABLE_ANIMATION"] !== "true" && (process.stdout.columns ?? 80) >= LOCKUP_WIDTH;
@@ -1795,8 +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 } from "fs/promises";
1799
- import { join as join4 } from "path";
1798
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2 } from "fs/promises";
1799
+ import { constants } from "fs";
1800
+ import { join as join4, resolve } from "path";
1800
1801
 
1801
1802
  // src/modules/git.ts
1802
1803
  import { execFile } from "child_process";
@@ -1893,7 +1894,7 @@ function buildGitEnv() {
1893
1894
  function runGit(cwd, args, options = {}) {
1894
1895
  const commandClass = options.commandClass ?? classifyGitCommand(args);
1895
1896
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
1896
- return new Promise((resolve2, reject) => {
1897
+ return new Promise((resolve3, reject) => {
1897
1898
  const child = execFile(
1898
1899
  "git",
1899
1900
  args,
@@ -1927,7 +1928,7 @@ function runGit(cwd, args, options = {}) {
1927
1928
  reject(error);
1928
1929
  return;
1929
1930
  }
1930
- resolve2({ stdout, stderr });
1931
+ resolve3({ stdout, stderr });
1931
1932
  }
1932
1933
  );
1933
1934
  child.stdin?.end();
@@ -2017,24 +2018,38 @@ async function ensureLocalPullRebase(cwd) {
2017
2018
  }
2018
2019
 
2019
2020
  // src/modules/push-safety.ts
2020
- var DISABLED_PUSH_URL = "arcane-push-blocked://this-repository-is-push-blocked";
2021
+ var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
2022
+ var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
2021
2023
  var ARCANE_HOOKS_DIR = ".arcane/hooks";
2022
2024
  async function readHooksPath(cwd) {
2023
- let effective;
2025
+ let raw;
2024
2026
  try {
2025
- const { stdout } = await runGit(cwd, ["config", "--get", "core.hooksPath"]);
2026
- effective = stdout.trim();
2027
- } catch {
2027
+ const { stdout } = await runGit(cwd, ["config", "--show-scope", "--get", "core.hooksPath"]);
2028
+ raw = stdout.trim();
2029
+ } catch (error) {
2030
+ if (error.code !== 1) {
2031
+ return { value: "", scope: "unknown", unreadable: true };
2032
+ }
2028
2033
  return void 0;
2029
2034
  }
2030
- if (effective === "") return void 0;
2031
- let local = "";
2032
- try {
2033
- const { stdout } = await runGit(cwd, ["config", "--local", "--get", "core.hooksPath"]);
2034
- local = stdout.trim();
2035
- } catch {
2036
- }
2037
- return { value: effective, scope: local === effective && local !== "" ? "local" : "inherited" };
2035
+ if (raw === "") return void 0;
2036
+ const separator = raw.indexOf(" ");
2037
+ if (separator === -1) return { value: raw, scope: "unknown" };
2038
+ const scope = raw.slice(0, separator).trim();
2039
+ const value = raw.slice(separator + 1).trim();
2040
+ if (value === "") return void 0;
2041
+ const known = ["local", "worktree", "global", "system", "command"];
2042
+ const matched = known.find((candidate) => candidate === scope);
2043
+ return { value, scope: matched ?? "unknown" };
2044
+ }
2045
+ function isArcaneHooksPath(cwd, value) {
2046
+ const windows = process.platform === "win32";
2047
+ const normalize = (p) => {
2048
+ const separated = windows ? p.replace(/\\/g, "/") : p;
2049
+ const absolute = resolve(cwd, separated).replace(/\\/g, "/").replace(/\/+$/, "");
2050
+ return windows ? absolute.toLowerCase() : absolute;
2051
+ };
2052
+ return normalize(value) === normalize(ARCANE_HOOKS_DIR);
2038
2053
  }
2039
2054
  var HOOK_BODY = `#!/bin/sh
2040
2055
  # Installed by Arcane because this repository's .arcane.json sets
@@ -2054,12 +2069,29 @@ var HOOK_BODY = `#!/bin/sh
2054
2069
  echo "arcane: push blocked (push_policy: blocked). Run 'spell unblock-push' to undo." >&2
2055
2070
  exit 1
2056
2071
  `;
2072
+ function describeConfigScope(scope) {
2073
+ switch (scope) {
2074
+ case "local":
2075
+ return "set for this repository";
2076
+ case "worktree":
2077
+ return "set for this worktree";
2078
+ case "global":
2079
+ return "set globally, for your user";
2080
+ case "system":
2081
+ return "set system-wide";
2082
+ case "command":
2083
+ return "set on the command line";
2084
+ default:
2085
+ return "scope unknown";
2086
+ }
2087
+ }
2057
2088
  function hookFilePath(cwd) {
2058
2089
  return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
2059
2090
  }
2060
2091
  async function installPrePushHook(cwd) {
2061
2092
  const existing = await readHooksPath(cwd);
2062
- if (existing !== void 0 && existing.value !== ARCANE_HOOKS_DIR) {
2093
+ if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
2094
+ if (existing !== void 0 && !isArcaneHooksPath(cwd, existing.value)) {
2063
2095
  return {
2064
2096
  status: "refused-foreign-hooks-path",
2065
2097
  existing: existing.value,
@@ -2067,10 +2099,7 @@ async function installPrePushHook(cwd) {
2067
2099
  };
2068
2100
  }
2069
2101
  const hookPath = hookFilePath(cwd);
2070
- if (existing?.value === ARCANE_HOOKS_DIR && await fileExists(hookPath)) {
2071
- const current = await readFile4(hookPath, "utf-8");
2072
- if (current === HOOK_BODY) return { status: "already-ours" };
2073
- }
2102
+ if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
2074
2103
  await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
2075
2104
  await writeFile5(hookPath, HOOK_BODY, "utf-8");
2076
2105
  await chmod(hookPath, 493);
@@ -2079,14 +2108,37 @@ async function installPrePushHook(cwd) {
2079
2108
  }
2080
2109
  async function isHookEnforced(cwd) {
2081
2110
  const hooksPath = await readHooksPath(cwd);
2082
- if (hooksPath?.value !== ARCANE_HOOKS_DIR) return false;
2083
- return fileExists(hookFilePath(cwd));
2111
+ if (hooksPath === void 0 || !isArcaneHooksPath(cwd, hooksPath.value)) return false;
2112
+ return hookBodyMatches(cwd);
2113
+ }
2114
+ async function hookBodyMatches(cwd) {
2115
+ const hookPath = hookFilePath(cwd);
2116
+ if (!await fileExists(hookPath)) return false;
2117
+ try {
2118
+ if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
2119
+ } catch {
2120
+ return false;
2121
+ }
2122
+ if (process.platform === "win32") return true;
2123
+ try {
2124
+ await access3(hookPath, constants.X_OK);
2125
+ return true;
2126
+ } catch {
2127
+ return false;
2128
+ }
2084
2129
  }
2085
2130
  async function removePrePushHook(cwd) {
2086
2131
  const existing = await readHooksPath(cwd);
2087
- if (existing?.value !== ARCANE_HOOKS_DIR || existing.scope !== "local") return;
2132
+ if (existing === void 0 || existing.unreadable === true) return;
2133
+ if (!isArcaneHooksPath(cwd, existing.value)) return;
2134
+ if (existing.scope !== "local" && existing.scope !== "worktree") return;
2135
+ const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2136
+ try {
2137
+ await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2138
+ } catch {
2139
+ }
2088
2140
  try {
2089
- await runGit(cwd, ["config", "--local", "--unset", "core.hooksPath"]);
2141
+ await rm2(hookFilePath(cwd), { force: true });
2090
2142
  } catch {
2091
2143
  }
2092
2144
  }
@@ -2098,62 +2150,182 @@ async function listRemotes(cwd) {
2098
2150
  return [];
2099
2151
  }
2100
2152
  }
2101
- async function disablePushUrls(cwd) {
2102
- const results = [];
2153
+ async function pushTargets(cwd) {
2154
+ const targets = [];
2103
2155
  for (const remote of await listRemotes(cwd)) {
2104
- let current;
2105
2156
  try {
2106
- const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
2107
- current = stdout.trim();
2157
+ const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", "--all", remote]);
2158
+ targets.push({
2159
+ remote,
2160
+ urls: stdout.split("\n").map((line) => line.trim()).filter(Boolean)
2161
+ });
2108
2162
  } catch {
2109
- continue;
2163
+ targets.push({ remote, urls: [] });
2110
2164
  }
2111
- if (current === DISABLED_PUSH_URL) {
2112
- results.push({ remote, status: "already-disabled" });
2113
- continue;
2165
+ }
2166
+ return targets;
2167
+ }
2168
+ function originalPushUrlKey(remote) {
2169
+ return `remote.${remote}.arcaneOriginalPushUrl`;
2170
+ }
2171
+ async function readLocalAll(cwd, key) {
2172
+ try {
2173
+ const { stdout } = await runGit(cwd, ["config", "--local", "--get-all", key]);
2174
+ return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
2175
+ } catch {
2176
+ return [];
2177
+ }
2178
+ }
2179
+ async function readScopedAll(cwd, key) {
2180
+ let stdout;
2181
+ try {
2182
+ ({ stdout } = await runGit(cwd, ["config", "--show-scope", "--get-all", key]));
2183
+ } catch {
2184
+ return [];
2185
+ }
2186
+ return stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean).map((line) => {
2187
+ const separator = line.indexOf(" ");
2188
+ if (separator === -1) return { scope: "unknown", value: line.trim() };
2189
+ return { scope: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
2190
+ }).filter((entry) => entry.value !== "");
2191
+ }
2192
+ async function effectivePushUrls(cwd, remote) {
2193
+ const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
2194
+ return scoped.map((entry) => entry.value);
2195
+ }
2196
+ async function disablePushUrls(cwd) {
2197
+ const results = [];
2198
+ for (const remote of await listRemotes(cwd)) {
2199
+ try {
2200
+ results.push(await disableOneRemote(cwd, remote));
2201
+ } catch (error) {
2202
+ results.push({ remote, status: "failed", reason: describeError(error) });
2114
2203
  }
2115
- await runGit(cwd, ["config", "--local", `arcane.originalPushUrl.${remote}`, current]);
2116
- await runGit(cwd, ["remote", "set-url", "--push", remote, DISABLED_PUSH_URL]);
2117
- results.push({ remote, status: "disabled" });
2118
2204
  }
2119
2205
  return results;
2120
2206
  }
2207
+ async function disableOneRemote(cwd, remote) {
2208
+ const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
2209
+ const foreign = scoped.filter((entry) => entry.scope !== "local" && entry.value !== DISABLED_PUSH_URL);
2210
+ if (foreign.length > 0) {
2211
+ const where = [...new Set(foreign.map((entry) => entry.scope))].join(", ");
2212
+ return {
2213
+ remote,
2214
+ 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.`
2216
+ };
2217
+ }
2218
+ const currentPushUrls = scoped.map((entry) => entry.value);
2219
+ if (currentPushUrls.length === 1 && currentPushUrls[0] === DISABLED_PUSH_URL) {
2220
+ return { remote, status: "already-disabled" };
2221
+ }
2222
+ const key = originalPushUrlKey(remote);
2223
+ if ((await readLocalAll(cwd, key)).length === 0 && !await hadNoPushUrl(cwd, remote)) {
2224
+ if (currentPushUrls.length > 0) {
2225
+ await runGit(cwd, ["config", "--local", "--replace-all", key, currentPushUrls[0]]);
2226
+ for (const extra of currentPushUrls.slice(1)) {
2227
+ await runGit(cwd, ["config", "--local", "--add", key, extra]);
2228
+ }
2229
+ } else {
2230
+ await runGit(cwd, ["config", "--local", NO_PUSH_URL_MARKER(remote), "true"]);
2231
+ }
2232
+ }
2233
+ await runGit(cwd, [
2234
+ "config",
2235
+ "--local",
2236
+ "--replace-all",
2237
+ `remote.${remote}.pushurl`,
2238
+ DISABLED_PUSH_URL
2239
+ ]);
2240
+ return { remote, status: "disabled" };
2241
+ }
2121
2242
  async function undisabledRemotes(cwd) {
2122
2243
  const open = [];
2123
2244
  for (const remote of await listRemotes(cwd)) {
2124
- try {
2125
- const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
2126
- if (stdout.trim() !== DISABLED_PUSH_URL) open.push(remote);
2127
- } catch {
2128
- open.push(remote);
2129
- }
2245
+ const urls = await effectivePushUrls(cwd, remote);
2246
+ if (urls.length === 0 || urls.some((url) => url !== DISABLED_PUSH_URL)) open.push(remote);
2130
2247
  }
2131
2248
  return open;
2132
2249
  }
2250
+ async function blockedRemotes(cwd) {
2251
+ const blocked = [];
2252
+ for (const remote of await listRemotes(cwd)) {
2253
+ const urls = await effectivePushUrls(cwd, remote);
2254
+ if (urls.some((url) => url === DISABLED_PUSH_URL)) blocked.push(remote);
2255
+ }
2256
+ return blocked;
2257
+ }
2133
2258
  async function restorePushUrls(cwd) {
2134
- const restored = [];
2259
+ const results = [];
2135
2260
  for (const remote of await listRemotes(cwd)) {
2136
- let original = "";
2137
2261
  try {
2138
- const { stdout } = await runGit(cwd, [
2139
- "config",
2140
- "--local",
2141
- "--get",
2142
- `arcane.originalPushUrl.${remote}`
2143
- ]);
2144
- original = stdout.trim();
2145
- } catch {
2146
- continue;
2262
+ results.push(await restoreOneRemote(cwd, remote));
2263
+ } catch (error) {
2264
+ results.push({ remote, status: "failed", reason: describeError(error) });
2265
+ }
2266
+ }
2267
+ return results;
2268
+ }
2269
+ async function restoreOneRemote(cwd, remote) {
2270
+ const key = originalPushUrlKey(remote);
2271
+ let recorded = await readLocalAll(cwd, key);
2272
+ let hadNone = await hadNoPushUrl(cwd, remote);
2273
+ if (recorded.length === 0 && !hadNone) {
2274
+ const legacy = await readLegacyRecord(cwd, remote);
2275
+ if (legacy.status !== "usable") return { remote, status: legacy.status, reason: legacy.reason };
2276
+ recorded = legacy.values;
2277
+ hadNone = false;
2278
+ }
2279
+ if (hadNone) {
2280
+ await runGit(cwd, ["config", "--local", "--unset-all", `remote.${remote}.pushurl`]);
2281
+ } else {
2282
+ await runGit(cwd, [
2283
+ "config",
2284
+ "--local",
2285
+ "--replace-all",
2286
+ `remote.${remote}.pushurl`,
2287
+ recorded[0]
2288
+ ]);
2289
+ for (const extra of recorded.slice(1)) {
2290
+ await runGit(cwd, ["config", "--local", "--add", `remote.${remote}.pushurl`, extra]);
2147
2291
  }
2148
- if (original === "") continue;
2149
- await runGit(cwd, ["remote", "set-url", "--push", remote, original]);
2292
+ }
2293
+ for (const staleKey of [key, NO_PUSH_URL_MARKER(remote), `arcane.originalPushUrl.${remote}`]) {
2150
2294
  try {
2151
- await runGit(cwd, ["config", "--local", "--unset", `arcane.originalPushUrl.${remote}`]);
2295
+ await runGit(cwd, ["config", "--local", "--unset-all", staleKey]);
2152
2296
  } catch {
2153
2297
  }
2154
- restored.push(remote);
2155
2298
  }
2156
- return restored;
2299
+ return { remote, status: "restored" };
2300
+ }
2301
+ async function hadNoPushUrl(cwd, remote) {
2302
+ return (await readLocalAll(cwd, NO_PUSH_URL_MARKER(remote)))[0] === "true";
2303
+ }
2304
+ async function readLegacyRecord(cwd, remote) {
2305
+ const values = await readLocalAll(cwd, `arcane.originalPushUrl.${remote}`);
2306
+ if (values.length === 0) return { status: "nothing-recorded" };
2307
+ const current = await effectivePushUrls(cwd, remote);
2308
+ if (!current.includes(DISABLED_PUSH_URL)) {
2309
+ return {
2310
+ status: "failed",
2311
+ reason: `found a push URL recorded by 0.20.0 ("${values.join(", ")}") but this remote is not currently blocked by Arcane, so the record may belong to a different remote of the same name. Not applying it \u2014 set the URL yourself if it is correct.`
2312
+ };
2313
+ }
2314
+ const names = await listRemotes(cwd);
2315
+ const collides = names.some(
2316
+ (other) => other !== remote && other.toLowerCase() === remote.toLowerCase()
2317
+ );
2318
+ if (collides) {
2319
+ return {
2320
+ status: "failed",
2321
+ reason: `this repository has remotes whose names differ only by case, and 0.20.0 stored their original push URLs under one shared key, so the recorded value ("${values.join(", ")}") cannot be attributed to a specific remote. Not applying it \u2014 set the URLs yourself.`
2322
+ };
2323
+ }
2324
+ return { status: "usable", values };
2325
+ }
2326
+ function describeError(error) {
2327
+ const message = error instanceof Error ? error.message : String(error);
2328
+ return message.split("\n")[0].trim();
2157
2329
  }
2158
2330
 
2159
2331
  // src/commands/init.ts
@@ -2483,22 +2655,33 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2483
2655
  try {
2484
2656
  if (push_policy === "blocked") {
2485
2657
  const hook = await installPrePushHook(targetDir);
2486
- if (hook.status === "refused-foreign-hooks-path") {
2658
+ if (hook.status === "refused-unreadable-config") {
2487
2659
  printWarning(
2488
- `Did not install the pre-push hook: core.hooksPath is already "${hook.existing}" (set ${hook.scope === "local" ? "for this repository" : "globally"}), 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.`
2660
+ "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
+ );
2662
+ } else if (hook.status === "refused-foreign-hooks-path") {
2663
+ printWarning(
2664
+ `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.`
2489
2665
  );
2490
2666
  } else {
2491
2667
  printInfo("Installed a pre-push hook that blocks pushes from this repository.");
2492
2668
  }
2493
2669
  const urls = await disablePushUrls(targetDir);
2670
+ const unprotected = urls.filter((u) => u.status === "failed");
2671
+ if (unprotected.length > 0) {
2672
+ printWarning(
2673
+ `Could not disable the push URL for: ${unprotected.map((u) => `${u.remote} (${u.reason ?? "unknown error"})`).join("; ")}. Those remotes are still pushable with \`--no-verify\`. Run \`spell doctor\` \u2014 it lists exactly which remotes are still live.`
2674
+ );
2675
+ }
2494
2676
  if (urls.length === 0) {
2495
2677
  printWarning(
2496
2678
  "No remote is configured, so only the pre-push hook is active. A remote added later will NOT have its push URL disabled automatically, and a `--no-verify` push to it would succeed. Run `spell doctor` after adding one \u2014 it reports this gap."
2497
2679
  );
2498
2680
  } else {
2499
- printInfo(
2500
- `Disabled the push URL for: ${urls.map((u) => u.remote).join(", ")} (fetch still works).`
2501
- );
2681
+ const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
2682
+ if (covered.length > 0) {
2683
+ printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
2684
+ }
2502
2685
  }
2503
2686
  printInfo("Run `spell unblock-push` from a terminal to undo this.");
2504
2687
  } else {
@@ -3130,7 +3313,7 @@ async function runStatus(targetDir, packageVersion) {
3130
3313
  }
3131
3314
 
3132
3315
  // src/commands/uninstall.ts
3133
- import { rm as rm2 } from "fs/promises";
3316
+ import { rm as rm3 } from "fs/promises";
3134
3317
  import { join as join10 } from "path";
3135
3318
  import { confirm as confirm3 } from "@inquirer/prompts";
3136
3319
  async function runUninstall(options, targetDir) {
@@ -3145,6 +3328,21 @@ async function runUninstall(options, targetDir) {
3145
3328
  }
3146
3329
  throw err;
3147
3330
  }
3331
+ const policy = manifest.push_policy ?? "open";
3332
+ const controlsInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
3333
+ if ((policy === "blocked" || controlsInForce) && !options.dryRun) {
3334
+ const why = policy === "blocked" ? `this repository's push_policy is "blocked"` : `this repository still has Arcane's push block in force (the manifest says "${policy}", but the controls are actually installed)`;
3335
+ console.error(
3336
+ `Refusing to uninstall: ${why}.
3337
+ Uninstalling now would delete .arcane.json while leaving the pre-push hook and the
3338
+ disabled push URLs in place \u2014 the repository could not push, and \`spell unblock-push\`
3339
+ would no longer recognise it.
3340
+
3341
+ Run \`spell unblock-push\` first (interactive), then uninstall.`
3342
+ );
3343
+ process.exit(1);
3344
+ return;
3345
+ }
3148
3346
  const allFiles = manifest.components.flatMap((c) => c.files);
3149
3347
  const totalFiles = allFiles.length;
3150
3348
  if (options.dryRun) {
@@ -3159,6 +3357,11 @@ async function runUninstall(options, targetDir) {
3159
3357
  console.log(`[dry-run] Would strip arcane sections from: CLAUDE.md, AGENTS.md, copilot-instructions.md`);
3160
3358
  console.log(`
3161
3359
  [dry-run] ${totalFiles} file(s) + manifest + agent outputs would be removed.`);
3360
+ if (policy === "blocked" || controlsInForce) {
3361
+ console.log(
3362
+ `[dry-run] BUT the real run would refuse: Arcane's push block is in force. Run \`spell unblock-push\` first.`
3363
+ );
3364
+ }
3162
3365
  return;
3163
3366
  }
3164
3367
  if (!options.yes) {
@@ -3176,22 +3379,22 @@ async function runUninstall(options, targetDir) {
3176
3379
  for (const file of component.files) {
3177
3380
  const filePath = join10(targetDir, file);
3178
3381
  try {
3179
- await rm2(filePath, { force: true });
3382
+ await rm3(filePath, { force: true });
3180
3383
  removed++;
3181
3384
  } catch {
3182
3385
  }
3183
3386
  }
3184
3387
  }
3185
- await rm2(join10(targetDir, ".arcane.json"), { force: true });
3388
+ await rm3(join10(targetDir, ".arcane.json"), { force: true });
3186
3389
  const agentDirs = [
3187
3390
  ".github/agents",
3188
3391
  ".arcane/agents",
3189
3392
  ".arcane/generated"
3190
3393
  ];
3191
3394
  for (const dir of agentDirs) {
3192
- await rm2(join10(targetDir, dir), { recursive: true, force: true });
3395
+ await rm3(join10(targetDir, dir), { recursive: true, force: true });
3193
3396
  }
3194
- await rm2(join10(targetDir, ".arcane/agents.yaml"), { force: true });
3397
+ await rm3(join10(targetDir, ".arcane/agents.yaml"), { force: true });
3195
3398
  const markerFiles = [
3196
3399
  "CLAUDE.md",
3197
3400
  "AGENTS.md",
@@ -3205,7 +3408,7 @@ async function runUninstall(options, targetDir) {
3205
3408
  }
3206
3409
 
3207
3410
  // src/commands/doctor.ts
3208
- import { access as access3, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3411
+ import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3209
3412
  import { join as join11, dirname as dirname2 } from "path";
3210
3413
  import { execFile as execFile2 } from "child_process";
3211
3414
  import { promisify } from "util";
@@ -3331,7 +3534,7 @@ async function checkArcaneManifest(targetDir) {
3331
3534
  const name = "Arcane manifest (.arcane.json)";
3332
3535
  const manifestPath2 = join11(targetDir, ".arcane.json");
3333
3536
  try {
3334
- await access3(manifestPath2);
3537
+ await access4(manifestPath2);
3335
3538
  } catch {
3336
3539
  const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
3337
3540
  try {
@@ -3427,7 +3630,7 @@ async function checkSessionContinuity(targetDir) {
3427
3630
  const name = `Session file: ${file}`;
3428
3631
  const filePath = join11(targetDir, file);
3429
3632
  try {
3430
- await access3(filePath);
3633
+ await access4(filePath);
3431
3634
  results.push({ name, passed: true, message: "present" });
3432
3635
  } catch {
3433
3636
  results.push({
@@ -3445,7 +3648,7 @@ async function fixSessionContinuity(targetDir, assetsDir) {
3445
3648
  for (const file of SESSION_CONTINUITY_FILES) {
3446
3649
  const destPath = join11(targetDir, file);
3447
3650
  try {
3448
- await access3(destPath);
3651
+ await access4(destPath);
3449
3652
  } catch {
3450
3653
  const srcPath = join11(assetsDir, file);
3451
3654
  await mkdir6(dirname2(destPath), { recursive: true });
@@ -3468,8 +3671,8 @@ async function checkPushPolicy(targetDir) {
3468
3671
  return { name, passed: true, message: "open \u2014 pushes allowed" };
3469
3672
  }
3470
3673
  if (policy === "guarded") {
3471
- const remotes2 = await listRemotes(targetDir);
3472
- const where = remotes2.length === 0 ? "none configured" : remotes2.join(", ");
3674
+ const targets = await pushTargets(targetDir);
3675
+ const where = targets.length === 0 ? "none configured" : targets.map(({ remote, urls }) => `${remote} \u2192 ${urls.length > 0 ? urls.join(", ") : "unknown"}`).join("; ");
3473
3676
  return {
3474
3677
  name,
3475
3678
  passed: false,
@@ -3559,7 +3762,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3559
3762
  }
3560
3763
 
3561
3764
  // src/commands/unblock-push.ts
3562
- import { basename, resolve } from "path";
3765
+ import { basename, resolve as resolve2 } from "path";
3563
3766
  import { input as input4 } from "@inquirer/prompts";
3564
3767
  async function runUnblockPush(targetDir) {
3565
3768
  let manifest;
@@ -3585,7 +3788,7 @@ async function runUnblockPush(targetDir) {
3585
3788
  process.exit(1);
3586
3789
  return;
3587
3790
  }
3588
- const repoName = basename(resolve(targetDir));
3791
+ const repoName = basename(resolve2(targetDir));
3589
3792
  if (repoName === "") {
3590
3793
  console.error(
3591
3794
  "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
@@ -3608,17 +3811,43 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
3608
3811
  return;
3609
3812
  }
3610
3813
  await removePrePushHook(targetDir);
3611
- const restored = await restorePushUrls(targetDir);
3814
+ const results = await restorePushUrls(targetDir);
3815
+ const restored = results.filter((r) => r.status === "restored").map((r) => r.remote);
3816
+ const failed = results.filter((r) => r.status === "failed");
3817
+ const stillBlocked = await blockedRemotes(targetDir);
3612
3818
  const updated = {
3613
3819
  ...manifest,
3614
3820
  push_policy: "open",
3615
3821
  push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
3616
3822
  };
3617
3823
  await writeManifest(targetDir, updated);
3618
- printSuccess(`Push unblocked for "${repoName}".`);
3824
+ const hookStillOn = await isHookEnforced(targetDir);
3825
+ const fullyLifted = failed.length === 0 && stillBlocked.length === 0 && !hookStillOn;
3826
+ if (fullyLifted) {
3827
+ printSuccess(`Push unblocked for "${repoName}".`);
3828
+ } else {
3829
+ printWarning(
3830
+ `Push only PARTIALLY unblocked for "${repoName}". The manifest now says open; the repository is not fully open yet. Details below.`
3831
+ );
3832
+ }
3619
3833
  if (restored.length > 0) {
3620
3834
  printInfo(`Restored push URL for: ${restored.join(", ")}.`);
3621
3835
  }
3836
+ if (hookStillOn) {
3837
+ printWarning(
3838
+ "The pre-push hook is still in force. Arcane did not remove it because `core.hooksPath` is not one it owns, or could not be read. Check `git config --show-scope --get core.hooksPath`."
3839
+ );
3840
+ }
3841
+ if (failed.length > 0) {
3842
+ printWarning(
3843
+ `Could not restore the push URL for: ${failed.map((r) => `${r.remote} (${r.reason ?? "unknown error"})`).join("; ")}.`
3844
+ );
3845
+ }
3846
+ if (stillBlocked.length > 0) {
3847
+ printWarning(
3848
+ `These remotes still have a blocked push URL and no recorded original: ${stillBlocked.join(", ")}. Set them yourself with \`git remote set-url --push <name> <url>\` \u2014 until then, pushing to them will fail even though the manifest now says open.`
3849
+ );
3850
+ }
3622
3851
  printInfo(
3623
3852
  "Recorded in .arcane.json as push_policy: open, with the time it was lifted. Commit that change so the repository's actual posture stays visible to everyone."
3624
3853
  );