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 +310 -81
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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((
|
|
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 {
|
|
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((
|
|
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
|
-
|
|
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
|
|
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
|
|
2025
|
+
let raw;
|
|
2024
2026
|
try {
|
|
2025
|
-
const { stdout } = await runGit(cwd, ["config", "--get", "core.hooksPath"]);
|
|
2026
|
-
|
|
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 (
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
|
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
|
|
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
|
|
2083
|
-
return
|
|
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
|
|
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
|
|
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
|
|
2102
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
2163
|
+
targets.push({ remote, urls: [] });
|
|
2110
2164
|
}
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
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
|
-
|
|
2125
|
-
|
|
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
|
|
2259
|
+
const results = [];
|
|
2135
2260
|
for (const remote of await listRemotes(cwd)) {
|
|
2136
|
-
let original = "";
|
|
2137
2261
|
try {
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
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
|
-
|
|
2149
|
-
|
|
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",
|
|
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-
|
|
2658
|
+
if (hook.status === "refused-unreadable-config") {
|
|
2487
2659
|
printWarning(
|
|
2488
|
-
|
|
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
|
-
|
|
2500
|
-
|
|
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
|
|
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
|
|
3382
|
+
await rm3(filePath, { force: true });
|
|
3180
3383
|
removed++;
|
|
3181
3384
|
} catch {
|
|
3182
3385
|
}
|
|
3183
3386
|
}
|
|
3184
3387
|
}
|
|
3185
|
-
await
|
|
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
|
|
3395
|
+
await rm3(join10(targetDir, dir), { recursive: true, force: true });
|
|
3193
3396
|
}
|
|
3194
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3472
|
-
const where =
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
);
|