arcane-cli 0.20.0 → 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";
@@ -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, readdir as readdir3 } from "fs/promises";
1799
+ import { constants } from "fs";
1800
+ import { join as join4, resolve, dirname, basename } 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(root, value) {
2046
+ const windows = process.platform === "win32";
2047
+ const normalize = (p) => {
2048
+ const separated = windows ? p.replace(/\\/g, "/") : p;
2049
+ const absolute = resolve(root, 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,39 +2069,123 @@ 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
  `;
2057
- function hookFilePath(cwd) {
2058
- return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
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
+ }
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, "/");
2059
2115
  }
2060
2116
  async function installPrePushHook(cwd) {
2117
+ const root = await repositoryRoot(cwd);
2061
2118
  const existing = await readHooksPath(cwd);
2062
- if (existing !== void 0 && existing.value !== ARCANE_HOOKS_DIR) {
2119
+ if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
2120
+ if (existing !== void 0 && !isArcaneHooksPath(root, existing.value)) {
2063
2121
  return {
2064
2122
  status: "refused-foreign-hooks-path",
2065
2123
  existing: existing.value,
2066
2124
  scope: existing.scope
2067
2125
  };
2068
2126
  }
2069
- 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" };
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
+ }
2073
2132
  }
2074
- await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
2133
+ const hookPath = join4(root, ARCANE_HOOKS_DIR, "pre-push");
2134
+ if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
2135
+ await mkdir4(join4(root, ARCANE_HOOKS_DIR), { recursive: true });
2075
2136
  await writeFile5(hookPath, HOOK_BODY, "utf-8");
2076
2137
  await chmod(hookPath, 493);
2077
- await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
2138
+ await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
2078
2139
  return { status: "installed", path: hookPath };
2079
2140
  }
2080
2141
  async function isHookEnforced(cwd) {
2081
2142
  const hooksPath = await readHooksPath(cwd);
2082
- if (hooksPath?.value !== ARCANE_HOOKS_DIR) return false;
2083
- return fileExists(hookFilePath(cwd));
2143
+ if (hooksPath === void 0 || hooksPath.unreadable === true) return false;
2144
+ if (!isArcaneHooksPath(await repositoryRoot(cwd), hooksPath.value)) return false;
2145
+ return hookBodyMatches(cwd);
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
+ }
2161
+ async function hookBodyMatches(cwd) {
2162
+ const hookPath = await hookFilePath(cwd);
2163
+ if (!await fileExists(hookPath)) return false;
2164
+ try {
2165
+ if (await readFile4(hookPath, "utf-8") !== HOOK_BODY) return false;
2166
+ } catch {
2167
+ return false;
2168
+ }
2169
+ if (process.platform === "win32") return true;
2170
+ try {
2171
+ await access3(hookPath, constants.X_OK);
2172
+ return true;
2173
+ } catch {
2174
+ return false;
2175
+ }
2084
2176
  }
2085
2177
  async function removePrePushHook(cwd) {
2086
2178
  const existing = await readHooksPath(cwd);
2087
- if (existing?.value !== ARCANE_HOOKS_DIR || existing.scope !== "local") return;
2179
+ if (existing === void 0 || existing.unreadable === true) return;
2180
+ if (!isArcaneHooksPath(await repositoryRoot(cwd), existing.value)) return;
2181
+ if (existing.scope !== "local" && existing.scope !== "worktree") return;
2182
+ const scopeFlag = existing.scope === "worktree" ? "--worktree" : "--local";
2183
+ try {
2184
+ await runGit(cwd, ["config", scopeFlag, "--unset", "core.hooksPath"]);
2185
+ } catch {
2186
+ }
2088
2187
  try {
2089
- await runGit(cwd, ["config", "--local", "--unset", "core.hooksPath"]);
2188
+ await rm2(await hookFilePath(cwd), { force: true });
2090
2189
  } catch {
2091
2190
  }
2092
2191
  }
@@ -2098,62 +2197,203 @@ async function listRemotes(cwd) {
2098
2197
  return [];
2099
2198
  }
2100
2199
  }
2101
- async function disablePushUrls(cwd) {
2102
- const results = [];
2200
+ async function pushTargets(cwd) {
2201
+ const targets = [];
2103
2202
  for (const remote of await listRemotes(cwd)) {
2104
- let current;
2105
2203
  try {
2106
- const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
2107
- current = stdout.trim();
2204
+ const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", "--all", remote]);
2205
+ targets.push({
2206
+ remote,
2207
+ urls: stdout.split("\n").map((line) => line.trim()).filter(Boolean)
2208
+ });
2108
2209
  } catch {
2109
- continue;
2210
+ targets.push({ remote, urls: [] });
2110
2211
  }
2111
- if (current === DISABLED_PUSH_URL) {
2112
- results.push({ remote, status: "already-disabled" });
2113
- continue;
2212
+ }
2213
+ return targets;
2214
+ }
2215
+ function originalPushUrlKey(remote) {
2216
+ return `remote.${remote}.arcaneOriginalPushUrl`;
2217
+ }
2218
+ async function readLocalAll(cwd, key) {
2219
+ try {
2220
+ const { stdout } = await runGit(cwd, ["config", "--local", "--get-all", key]);
2221
+ return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
2222
+ } catch {
2223
+ return [];
2224
+ }
2225
+ }
2226
+ async function readScopedAll(cwd, key) {
2227
+ let stdout;
2228
+ try {
2229
+ ({ stdout } = await runGit(cwd, ["config", "--show-scope", "--get-all", key]));
2230
+ } catch {
2231
+ return [];
2232
+ }
2233
+ return stdout.split("\n").filter((line) => line !== "").map((line) => {
2234
+ const separator = line.indexOf(" ");
2235
+ if (separator === -1) return { scope: "unknown", value: line.trim() };
2236
+ return { scope: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
2237
+ });
2238
+ }
2239
+ async function effectivePushUrls(cwd, remote) {
2240
+ const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
2241
+ return scoped.map((entry) => entry.value);
2242
+ }
2243
+ async function disablePushUrls(cwd) {
2244
+ const results = [];
2245
+ for (const remote of await listRemotes(cwd)) {
2246
+ try {
2247
+ results.push(await disableOneRemote(cwd, remote));
2248
+ } catch (error) {
2249
+ results.push({ remote, status: "failed", reason: describeError(error) });
2114
2250
  }
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
2251
  }
2119
2252
  return results;
2120
2253
  }
2254
+ async function disableOneRemote(cwd, remote) {
2255
+ const scoped = await readScopedAll(cwd, `remote.${remote}.pushurl`);
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
+ );
2260
+ if (foreign.length > 0) {
2261
+ const scopes = [...new Set(foreign.map((entry) => entry.scope))];
2262
+ const where = scopes.join(", ");
2263
+ const flag = scopes[0] === "unknown" ? "<scope>" : `--${scopes[0]}`;
2264
+ return {
2265
+ remote,
2266
+ status: "failed",
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.`
2268
+ };
2269
+ }
2270
+ const currentPushUrls = scoped.map((entry) => entry.value);
2271
+ if (currentPushUrls.length === 1 && currentPushUrls[0] === DISABLED_PUSH_URL) {
2272
+ return { remote, status: "already-disabled" };
2273
+ }
2274
+ const key = originalPushUrlKey(remote);
2275
+ if ((await readLocalAll(cwd, key)).length === 0 && !await hadNoPushUrl(cwd, remote)) {
2276
+ if (currentPushUrls.length > 0) {
2277
+ await runGit(cwd, ["config", "--local", "--replace-all", key, currentPushUrls[0]]);
2278
+ for (const extra of currentPushUrls.slice(1)) {
2279
+ await runGit(cwd, ["config", "--local", "--add", key, extra]);
2280
+ }
2281
+ } else {
2282
+ await runGit(cwd, ["config", "--local", NO_PUSH_URL_MARKER(remote), "true"]);
2283
+ }
2284
+ }
2285
+ await runGit(cwd, [
2286
+ "config",
2287
+ "--local",
2288
+ "--replace-all",
2289
+ `remote.${remote}.pushurl`,
2290
+ DISABLED_PUSH_URL
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
+ }
2301
+ return { remote, status: "disabled" };
2302
+ }
2121
2303
  async function undisabledRemotes(cwd) {
2122
2304
  const open = [];
2123
2305
  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
- }
2306
+ const urls = await effectivePushUrls(cwd, remote);
2307
+ if (urls.length === 0 || urls.some((url) => url !== DISABLED_PUSH_URL)) open.push(remote);
2130
2308
  }
2131
2309
  return open;
2132
2310
  }
2311
+ async function blockedRemotes(cwd) {
2312
+ const blocked = [];
2313
+ for (const remote of await listRemotes(cwd)) {
2314
+ const urls = await effectivePushUrls(cwd, remote);
2315
+ if (urls.some((url) => url === DISABLED_PUSH_URL)) blocked.push(remote);
2316
+ }
2317
+ return blocked;
2318
+ }
2133
2319
  async function restorePushUrls(cwd) {
2134
- const restored = [];
2320
+ const results = [];
2135
2321
  for (const remote of await listRemotes(cwd)) {
2136
- let original = "";
2137
2322
  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;
2323
+ results.push(await restoreOneRemote(cwd, remote));
2324
+ } catch (error) {
2325
+ results.push({ remote, status: "failed", reason: describeError(error) });
2326
+ }
2327
+ }
2328
+ return results;
2329
+ }
2330
+ async function restoreOneRemote(cwd, remote) {
2331
+ const key = originalPushUrlKey(remote);
2332
+ let recorded = await readLocalAll(cwd, key);
2333
+ let hadNone = await hadNoPushUrl(cwd, remote);
2334
+ if (recorded.length === 0 && !hadNone) {
2335
+ const legacy = await readLegacyRecord(cwd, remote);
2336
+ if (legacy.status !== "usable") return { remote, status: legacy.status, reason: legacy.reason };
2337
+ recorded = legacy.values;
2338
+ hadNone = false;
2339
+ }
2340
+ if (hadNone) {
2341
+ await unsetIgnoringMissing(cwd, `remote.${remote}.pushurl`);
2342
+ } else {
2343
+ await runGit(cwd, [
2344
+ "config",
2345
+ "--local",
2346
+ "--replace-all",
2347
+ `remote.${remote}.pushurl`,
2348
+ recorded[0]
2349
+ ]);
2350
+ for (const extra of recorded.slice(1)) {
2351
+ await runGit(cwd, ["config", "--local", "--add", `remote.${remote}.pushurl`, extra]);
2147
2352
  }
2148
- if (original === "") continue;
2149
- await runGit(cwd, ["remote", "set-url", "--push", remote, original]);
2353
+ }
2354
+ for (const staleKey of [key, NO_PUSH_URL_MARKER(remote), `arcane.originalPushUrl.${remote}`]) {
2150
2355
  try {
2151
- await runGit(cwd, ["config", "--local", "--unset", `arcane.originalPushUrl.${remote}`]);
2356
+ await unsetIgnoringMissing(cwd, staleKey);
2152
2357
  } catch {
2153
2358
  }
2154
- restored.push(remote);
2155
2359
  }
2156
- return restored;
2360
+ return { remote, status: "restored" };
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
+ }
2369
+ async function hadNoPushUrl(cwd, remote) {
2370
+ return (await readLocalAll(cwd, NO_PUSH_URL_MARKER(remote)))[0] === "true";
2371
+ }
2372
+ async function readLegacyRecord(cwd, remote) {
2373
+ const values = await readLocalAll(cwd, `arcane.originalPushUrl.${remote}`);
2374
+ if (values.length === 0) return { status: "nothing-recorded" };
2375
+ const current = await effectivePushUrls(cwd, remote);
2376
+ if (!current.includes(DISABLED_PUSH_URL)) {
2377
+ return {
2378
+ status: "failed",
2379
+ 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.`
2380
+ };
2381
+ }
2382
+ const names = await listRemotes(cwd);
2383
+ const collides = names.some(
2384
+ (other) => other !== remote && other.toLowerCase() === remote.toLowerCase()
2385
+ );
2386
+ if (collides) {
2387
+ return {
2388
+ status: "failed",
2389
+ 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.`
2390
+ };
2391
+ }
2392
+ return { status: "usable", values };
2393
+ }
2394
+ function describeError(error) {
2395
+ const message = error instanceof Error ? error.message : String(error);
2396
+ return message.split("\n")[0].trim();
2157
2397
  }
2158
2398
 
2159
2399
  // src/commands/init.ts
@@ -2483,22 +2723,37 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
2483
2723
  try {
2484
2724
  if (push_policy === "blocked") {
2485
2725
  const hook = await installPrePushHook(targetDir);
2486
- if (hook.status === "refused-foreign-hooks-path") {
2726
+ if (hook.status === "refused-unreadable-config") {
2487
2727
  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.`
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`."
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
+ );
2734
+ } else if (hook.status === "refused-foreign-hooks-path") {
2735
+ printWarning(
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.`
2489
2737
  );
2490
2738
  } else {
2491
2739
  printInfo("Installed a pre-push hook that blocks pushes from this repository.");
2492
2740
  }
2493
2741
  const urls = await disablePushUrls(targetDir);
2742
+ const unprotected = urls.filter((u) => u.status === "failed");
2743
+ if (unprotected.length > 0) {
2744
+ printWarning(
2745
+ `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.`
2746
+ );
2747
+ }
2494
2748
  if (urls.length === 0) {
2495
2749
  printWarning(
2496
2750
  "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
2751
  );
2498
2752
  } else {
2499
- printInfo(
2500
- `Disabled the push URL for: ${urls.map((u) => u.remote).join(", ")} (fetch still works).`
2501
- );
2753
+ const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
2754
+ if (covered.length > 0) {
2755
+ printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
2756
+ }
2502
2757
  }
2503
2758
  printInfo("Run `spell unblock-push` from a terminal to undo this.");
2504
2759
  } else {
@@ -2636,7 +2891,7 @@ import { join as join8 } from "path";
2636
2891
 
2637
2892
  // src/modules/hub.ts
2638
2893
  import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
2639
- import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
2894
+ import { readdir as readdir4, writeFile as writeFile6 } from "fs/promises";
2640
2895
  import { join as join7 } from "path";
2641
2896
  var MANIFEST_RETROFITS = [
2642
2897
  {
@@ -2780,7 +3035,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
2780
3035
  const businessRootPath = join7(targetDir, businessRoot);
2781
3036
  let ventureDirs;
2782
3037
  try {
2783
- const entries = await readdir3(businessRootPath, { withFileTypes: true });
3038
+ const entries = await readdir4(businessRootPath, { withFileTypes: true });
2784
3039
  ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
2785
3040
  } catch {
2786
3041
  return;
@@ -2987,7 +3242,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
2987
3242
 
2988
3243
  // src/modules/version-check.ts
2989
3244
  import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
2990
- import { dirname, join as join9 } from "path";
3245
+ import { dirname as dirname2, join as join9 } from "path";
2991
3246
  import { homedir as homedir2 } from "os";
2992
3247
  import { createRequire } from "module";
2993
3248
  var require2 = createRequire(import.meta.url);
@@ -3016,7 +3271,7 @@ async function readCache(cachePath) {
3016
3271
  }
3017
3272
  }
3018
3273
  async function writeCache(cachePath, latest) {
3019
- await mkdir5(dirname(cachePath), { recursive: true });
3274
+ await mkdir5(dirname2(cachePath), { recursive: true });
3020
3275
  const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
3021
3276
  await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
3022
3277
  }
@@ -3130,7 +3385,7 @@ async function runStatus(targetDir, packageVersion) {
3130
3385
  }
3131
3386
 
3132
3387
  // src/commands/uninstall.ts
3133
- import { rm as rm2 } from "fs/promises";
3388
+ import { rm as rm3 } from "fs/promises";
3134
3389
  import { join as join10 } from "path";
3135
3390
  import { confirm as confirm3 } from "@inquirer/prompts";
3136
3391
  async function runUninstall(options, targetDir) {
@@ -3145,6 +3400,21 @@ async function runUninstall(options, targetDir) {
3145
3400
  }
3146
3401
  throw err;
3147
3402
  }
3403
+ const policy = manifest.push_policy ?? "open";
3404
+ const controlsInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
3405
+ if ((policy === "blocked" || controlsInForce) && !options.dryRun) {
3406
+ 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)`;
3407
+ console.error(
3408
+ `Refusing to uninstall: ${why}.
3409
+ Uninstalling now would delete .arcane.json while leaving the pre-push hook and the
3410
+ disabled push URLs in place \u2014 the repository could not push, and \`spell unblock-push\`
3411
+ would no longer recognise it.
3412
+
3413
+ Run \`spell unblock-push\` first (interactive), then uninstall.`
3414
+ );
3415
+ process.exit(1);
3416
+ return;
3417
+ }
3148
3418
  const allFiles = manifest.components.flatMap((c) => c.files);
3149
3419
  const totalFiles = allFiles.length;
3150
3420
  if (options.dryRun) {
@@ -3159,6 +3429,11 @@ async function runUninstall(options, targetDir) {
3159
3429
  console.log(`[dry-run] Would strip arcane sections from: CLAUDE.md, AGENTS.md, copilot-instructions.md`);
3160
3430
  console.log(`
3161
3431
  [dry-run] ${totalFiles} file(s) + manifest + agent outputs would be removed.`);
3432
+ if (policy === "blocked" || controlsInForce) {
3433
+ console.log(
3434
+ `[dry-run] BUT the real run would refuse: Arcane's push block is in force. Run \`spell unblock-push\` first.`
3435
+ );
3436
+ }
3162
3437
  return;
3163
3438
  }
3164
3439
  if (!options.yes) {
@@ -3176,22 +3451,22 @@ async function runUninstall(options, targetDir) {
3176
3451
  for (const file of component.files) {
3177
3452
  const filePath = join10(targetDir, file);
3178
3453
  try {
3179
- await rm2(filePath, { force: true });
3454
+ await rm3(filePath, { force: true });
3180
3455
  removed++;
3181
3456
  } catch {
3182
3457
  }
3183
3458
  }
3184
3459
  }
3185
- await rm2(join10(targetDir, ".arcane.json"), { force: true });
3460
+ await rm3(join10(targetDir, ".arcane.json"), { force: true });
3186
3461
  const agentDirs = [
3187
3462
  ".github/agents",
3188
3463
  ".arcane/agents",
3189
3464
  ".arcane/generated"
3190
3465
  ];
3191
3466
  for (const dir of agentDirs) {
3192
- await rm2(join10(targetDir, dir), { recursive: true, force: true });
3467
+ await rm3(join10(targetDir, dir), { recursive: true, force: true });
3193
3468
  }
3194
- await rm2(join10(targetDir, ".arcane/agents.yaml"), { force: true });
3469
+ await rm3(join10(targetDir, ".arcane/agents.yaml"), { force: true });
3195
3470
  const markerFiles = [
3196
3471
  "CLAUDE.md",
3197
3472
  "AGENTS.md",
@@ -3205,8 +3480,8 @@ async function runUninstall(options, targetDir) {
3205
3480
  }
3206
3481
 
3207
3482
  // src/commands/doctor.ts
3208
- import { access as access3, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3209
- import { join as join11, dirname as dirname2 } from "path";
3483
+ import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
3484
+ import { join as join11, dirname as dirname3 } from "path";
3210
3485
  import { execFile as execFile2 } from "child_process";
3211
3486
  import { promisify } from "util";
3212
3487
 
@@ -3299,8 +3574,8 @@ async function checkVSCodeExtension(id, label) {
3299
3574
  ];
3300
3575
  for (const dir of extDirs) {
3301
3576
  try {
3302
- const { readdir: readdir4 } = await import("fs/promises");
3303
- const entries = await readdir4(dir);
3577
+ const { readdir: readdir5 } = await import("fs/promises");
3578
+ const entries = await readdir5(dir);
3304
3579
  const prefix = id.toLowerCase() + "-";
3305
3580
  const found = entries.some(
3306
3581
  (e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
@@ -3331,7 +3606,7 @@ async function checkArcaneManifest(targetDir) {
3331
3606
  const name = "Arcane manifest (.arcane.json)";
3332
3607
  const manifestPath2 = join11(targetDir, ".arcane.json");
3333
3608
  try {
3334
- await access3(manifestPath2);
3609
+ await access4(manifestPath2);
3335
3610
  } catch {
3336
3611
  const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
3337
3612
  try {
@@ -3427,7 +3702,7 @@ async function checkSessionContinuity(targetDir) {
3427
3702
  const name = `Session file: ${file}`;
3428
3703
  const filePath = join11(targetDir, file);
3429
3704
  try {
3430
- await access3(filePath);
3705
+ await access4(filePath);
3431
3706
  results.push({ name, passed: true, message: "present" });
3432
3707
  } catch {
3433
3708
  results.push({
@@ -3445,10 +3720,10 @@ async function fixSessionContinuity(targetDir, assetsDir) {
3445
3720
  for (const file of SESSION_CONTINUITY_FILES) {
3446
3721
  const destPath = join11(targetDir, file);
3447
3722
  try {
3448
- await access3(destPath);
3723
+ await access4(destPath);
3449
3724
  } catch {
3450
3725
  const srcPath = join11(assetsDir, file);
3451
- await mkdir6(dirname2(destPath), { recursive: true });
3726
+ await mkdir6(dirname3(destPath), { recursive: true });
3452
3727
  await fsCopyFile2(srcPath, destPath);
3453
3728
  created.push(file);
3454
3729
  }
@@ -3468,8 +3743,8 @@ async function checkPushPolicy(targetDir) {
3468
3743
  return { name, passed: true, message: "open \u2014 pushes allowed" };
3469
3744
  }
3470
3745
  if (policy === "guarded") {
3471
- const remotes2 = await listRemotes(targetDir);
3472
- const where = remotes2.length === 0 ? "none configured" : remotes2.join(", ");
3746
+ const targets = await pushTargets(targetDir);
3747
+ const where = targets.length === 0 ? "none configured" : targets.map(({ remote, urls }) => `${remote} \u2192 ${urls.length > 0 ? urls.join(", ") : "unknown"}`).join("; ");
3473
3748
  return {
3474
3749
  name,
3475
3750
  passed: false,
@@ -3559,7 +3834,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
3559
3834
  }
3560
3835
 
3561
3836
  // src/commands/unblock-push.ts
3562
- import { basename, resolve } from "path";
3837
+ import { basename as basename2, resolve as resolve2 } from "path";
3563
3838
  import { input as input4 } from "@inquirer/prompts";
3564
3839
  async function runUnblockPush(targetDir) {
3565
3840
  let manifest;
@@ -3574,10 +3849,16 @@ async function runUnblockPush(targetDir) {
3574
3849
  throw err;
3575
3850
  }
3576
3851
  const policy = manifest.push_policy ?? "open";
3577
- if (policy === "open") {
3852
+ const stillInForce = await isHookEnforced(targetDir) || (await blockedRemotes(targetDir)).length > 0;
3853
+ if (policy === "open" && !stillInForce) {
3578
3854
  printInfo("This repository is not push-blocked \u2014 nothing to undo.");
3579
3855
  return;
3580
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
+ }
3581
3862
  if (!process.stdin.isTTY) {
3582
3863
  console.error(
3583
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."
@@ -3585,7 +3866,7 @@ async function runUnblockPush(targetDir) {
3585
3866
  process.exit(1);
3586
3867
  return;
3587
3868
  }
3588
- const repoName = basename(resolve(targetDir));
3869
+ const repoName = basename2(resolve2(targetDir));
3589
3870
  if (repoName === "") {
3590
3871
  console.error(
3591
3872
  "Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
@@ -3608,17 +3889,43 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
3608
3889
  return;
3609
3890
  }
3610
3891
  await removePrePushHook(targetDir);
3611
- const restored = await restorePushUrls(targetDir);
3892
+ const results = await restorePushUrls(targetDir);
3893
+ const restored = results.filter((r) => r.status === "restored").map((r) => r.remote);
3894
+ const failed = results.filter((r) => r.status === "failed");
3895
+ const stillBlocked = await blockedRemotes(targetDir);
3612
3896
  const updated = {
3613
3897
  ...manifest,
3614
3898
  push_policy: "open",
3615
3899
  push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
3616
3900
  };
3617
3901
  await writeManifest(targetDir, updated);
3618
- printSuccess(`Push unblocked for "${repoName}".`);
3902
+ const hookStillOn = await isHookEnforced(targetDir);
3903
+ const fullyLifted = failed.length === 0 && stillBlocked.length === 0 && !hookStillOn;
3904
+ if (fullyLifted) {
3905
+ printSuccess(`Push unblocked for "${repoName}".`);
3906
+ } else {
3907
+ printWarning(
3908
+ `Push only PARTIALLY unblocked for "${repoName}". The manifest now says open; the repository is not fully open yet. Details below.`
3909
+ );
3910
+ }
3619
3911
  if (restored.length > 0) {
3620
3912
  printInfo(`Restored push URL for: ${restored.join(", ")}.`);
3621
3913
  }
3914
+ if (hookStillOn) {
3915
+ printWarning(
3916
+ "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`."
3917
+ );
3918
+ }
3919
+ if (failed.length > 0) {
3920
+ printWarning(
3921
+ `Could not restore the push URL for: ${failed.map((r) => `${r.remote} (${r.reason ?? "unknown error"})`).join("; ")}.`
3922
+ );
3923
+ }
3924
+ if (stillBlocked.length > 0) {
3925
+ printWarning(
3926
+ `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.`
3927
+ );
3928
+ }
3622
3929
  printInfo(
3623
3930
  "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
3931
  );
@@ -3628,7 +3935,7 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
3628
3935
  import { realpathSync } from "fs";
3629
3936
  var require3 = createRequire2(import.meta.url);
3630
3937
  var pkg = require3("../package.json");
3631
- 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");
3632
3939
  var program = new Command();
3633
3940
  program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
3634
3941
  await printWelcome(pkg.version, process.cwd());