arcane-cli 0.19.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 +620 -51
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,11 +6,11 @@ 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
|
|
9
|
+
import { dirname as dirname3, 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";
|
|
13
|
-
import { join as
|
|
13
|
+
import { join as join5 } from "path";
|
|
14
14
|
import chalk2 from "chalk";
|
|
15
15
|
|
|
16
16
|
// src/modules/copier.ts
|
|
@@ -99,6 +99,7 @@ var MANIFEST_FILE = ".arcane.json";
|
|
|
99
99
|
var VALID_TRACKING_MODES = ["internal", "external"];
|
|
100
100
|
var VALID_EXTERNAL_PROVIDERS = ["ado", "jira", "other"];
|
|
101
101
|
var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
|
|
102
|
+
var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
|
|
102
103
|
function isValidSubjectRoot(value) {
|
|
103
104
|
const invalid = value.trim() === "" || path2.isAbsolute(value) || // Windows drive-relative ("C:docs") and UNC-ish values also escape.
|
|
104
105
|
/^[a-zA-Z]:/.test(value) || value.startsWith("\\\\") || value.startsWith("\\") || value.split(/[\\/]/).includes("..") || // A stored subject_root is interpolated into agent prompts downstream, so
|
|
@@ -128,6 +129,9 @@ function validateTrackingFields(manifest, filePath) {
|
|
|
128
129
|
manifest.content_sensitivity
|
|
129
130
|
);
|
|
130
131
|
}
|
|
132
|
+
if (manifest.push_policy !== void 0 && !VALID_PUSH_POLICIES.includes(manifest.push_policy)) {
|
|
133
|
+
throw new ManifestInvalidFieldError(filePath, "push_policy", manifest.push_policy);
|
|
134
|
+
}
|
|
131
135
|
if (manifest.subject_root !== void 0 && manifest.subject_root !== null) {
|
|
132
136
|
if (typeof manifest.subject_root !== "string") {
|
|
133
137
|
throw new ManifestInvalidFieldError(filePath, "subject_root", manifest.subject_root);
|
|
@@ -668,7 +672,7 @@ var WORDMARK = [
|
|
|
668
672
|
var INDENT = " ";
|
|
669
673
|
var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
|
|
670
674
|
function sleep(ms) {
|
|
671
|
-
return new Promise((
|
|
675
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
672
676
|
}
|
|
673
677
|
function isAnimatable() {
|
|
674
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;
|
|
@@ -1790,6 +1794,11 @@ async function runAgentsList(targetDir) {
|
|
|
1790
1794
|
console.log();
|
|
1791
1795
|
}
|
|
1792
1796
|
|
|
1797
|
+
// src/modules/push-safety.ts
|
|
1798
|
+
import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod, access as access3, rm as rm2 } from "fs/promises";
|
|
1799
|
+
import { constants } from "fs";
|
|
1800
|
+
import { join as join4, resolve } from "path";
|
|
1801
|
+
|
|
1793
1802
|
// src/modules/git.ts
|
|
1794
1803
|
import { execFile } from "child_process";
|
|
1795
1804
|
var GitTimeoutError = class extends Error {
|
|
@@ -1885,7 +1894,7 @@ function buildGitEnv() {
|
|
|
1885
1894
|
function runGit(cwd, args, options = {}) {
|
|
1886
1895
|
const commandClass = options.commandClass ?? classifyGitCommand(args);
|
|
1887
1896
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
|
|
1888
|
-
return new Promise((
|
|
1897
|
+
return new Promise((resolve3, reject) => {
|
|
1889
1898
|
const child = execFile(
|
|
1890
1899
|
"git",
|
|
1891
1900
|
args,
|
|
@@ -1919,7 +1928,7 @@ function runGit(cwd, args, options = {}) {
|
|
|
1919
1928
|
reject(error);
|
|
1920
1929
|
return;
|
|
1921
1930
|
}
|
|
1922
|
-
|
|
1931
|
+
resolve3({ stdout, stderr });
|
|
1923
1932
|
}
|
|
1924
1933
|
);
|
|
1925
1934
|
child.stdin?.end();
|
|
@@ -2008,6 +2017,317 @@ async function ensureLocalPullRebase(cwd) {
|
|
|
2008
2017
|
}
|
|
2009
2018
|
}
|
|
2010
2019
|
|
|
2020
|
+
// src/modules/push-safety.ts
|
|
2021
|
+
var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
|
|
2022
|
+
var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
|
|
2023
|
+
var ARCANE_HOOKS_DIR = ".arcane/hooks";
|
|
2024
|
+
async function readHooksPath(cwd) {
|
|
2025
|
+
let raw;
|
|
2026
|
+
try {
|
|
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
|
+
}
|
|
2033
|
+
return void 0;
|
|
2034
|
+
}
|
|
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);
|
|
2053
|
+
}
|
|
2054
|
+
var HOOK_BODY = `#!/bin/sh
|
|
2055
|
+
# Installed by Arcane because this repository's .arcane.json sets
|
|
2056
|
+
# push_policy: "blocked".
|
|
2057
|
+
#
|
|
2058
|
+
# This is deliberately not tamper-proof. It guards against an accidental push
|
|
2059
|
+
# -- the wrong remote, muscle memory, an unsupervised agent -- not against a
|
|
2060
|
+
# determined operator, who can always bypass it.
|
|
2061
|
+
#
|
|
2062
|
+
# To push legitimately, undo the policy first:
|
|
2063
|
+
#
|
|
2064
|
+
# spell unblock-push
|
|
2065
|
+
#
|
|
2066
|
+
# That command is interactive-only and asks you to confirm the repository by
|
|
2067
|
+
# name. Do not work around this hook by other means: the manifest would then
|
|
2068
|
+
# claim a protection this repository no longer has.
|
|
2069
|
+
echo "arcane: push blocked (push_policy: blocked). Run 'spell unblock-push' to undo." >&2
|
|
2070
|
+
exit 1
|
|
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
|
+
}
|
|
2088
|
+
function hookFilePath(cwd) {
|
|
2089
|
+
return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
|
|
2090
|
+
}
|
|
2091
|
+
async function installPrePushHook(cwd) {
|
|
2092
|
+
const existing = await readHooksPath(cwd);
|
|
2093
|
+
if (existing?.unreadable === true) return { status: "refused-unreadable-config" };
|
|
2094
|
+
if (existing !== void 0 && !isArcaneHooksPath(cwd, existing.value)) {
|
|
2095
|
+
return {
|
|
2096
|
+
status: "refused-foreign-hooks-path",
|
|
2097
|
+
existing: existing.value,
|
|
2098
|
+
scope: existing.scope
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
const hookPath = hookFilePath(cwd);
|
|
2102
|
+
if (existing !== void 0 && await hookBodyMatches(cwd)) return { status: "already-ours" };
|
|
2103
|
+
await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
|
|
2104
|
+
await writeFile5(hookPath, HOOK_BODY, "utf-8");
|
|
2105
|
+
await chmod(hookPath, 493);
|
|
2106
|
+
await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
|
|
2107
|
+
return { status: "installed", path: hookPath };
|
|
2108
|
+
}
|
|
2109
|
+
async function isHookEnforced(cwd) {
|
|
2110
|
+
const hooksPath = await readHooksPath(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
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
async function removePrePushHook(cwd) {
|
|
2131
|
+
const existing = await readHooksPath(cwd);
|
|
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
|
+
}
|
|
2140
|
+
try {
|
|
2141
|
+
await rm2(hookFilePath(cwd), { force: true });
|
|
2142
|
+
} catch {
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
async function listRemotes(cwd) {
|
|
2146
|
+
try {
|
|
2147
|
+
const { stdout } = await runGit(cwd, ["remote"]);
|
|
2148
|
+
return stdout.split("\n").map((r) => r.trim()).filter(Boolean);
|
|
2149
|
+
} catch {
|
|
2150
|
+
return [];
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
async function pushTargets(cwd) {
|
|
2154
|
+
const targets = [];
|
|
2155
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2156
|
+
try {
|
|
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
|
+
});
|
|
2162
|
+
} catch {
|
|
2163
|
+
targets.push({ remote, urls: [] });
|
|
2164
|
+
}
|
|
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) });
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
return results;
|
|
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
|
+
}
|
|
2242
|
+
async function undisabledRemotes(cwd) {
|
|
2243
|
+
const open = [];
|
|
2244
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2245
|
+
const urls = await effectivePushUrls(cwd, remote);
|
|
2246
|
+
if (urls.length === 0 || urls.some((url) => url !== DISABLED_PUSH_URL)) open.push(remote);
|
|
2247
|
+
}
|
|
2248
|
+
return open;
|
|
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
|
+
}
|
|
2258
|
+
async function restorePushUrls(cwd) {
|
|
2259
|
+
const results = [];
|
|
2260
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2261
|
+
try {
|
|
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]);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
for (const staleKey of [key, NO_PUSH_URL_MARKER(remote), `arcane.originalPushUrl.${remote}`]) {
|
|
2294
|
+
try {
|
|
2295
|
+
await runGit(cwd, ["config", "--local", "--unset-all", staleKey]);
|
|
2296
|
+
} catch {
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
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();
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2011
2331
|
// src/commands/init.ts
|
|
2012
2332
|
var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
|
|
2013
2333
|
var PROFILE_ICONS = {
|
|
@@ -2170,7 +2490,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2170
2490
|
for (const component of components) {
|
|
2171
2491
|
const installedFiles = [];
|
|
2172
2492
|
for (const file of component.files) {
|
|
2173
|
-
const srcPath =
|
|
2493
|
+
const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2174
2494
|
if (options.dryRun) {
|
|
2175
2495
|
printDryRun(`Would copy: ${file}`);
|
|
2176
2496
|
} else {
|
|
@@ -2187,7 +2507,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2187
2507
|
fileCount++;
|
|
2188
2508
|
}
|
|
2189
2509
|
for (const dir of component.directories ?? []) {
|
|
2190
|
-
const srcDirPath =
|
|
2510
|
+
const srcDirPath = join5(assetsDir, dir);
|
|
2191
2511
|
if (options.dryRun) {
|
|
2192
2512
|
printDryRun(`Would copy directory: ${dir}/`);
|
|
2193
2513
|
} else {
|
|
@@ -2212,7 +2532,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2212
2532
|
if (options.dryRun) {
|
|
2213
2533
|
if (hasAgents) {
|
|
2214
2534
|
const agentFiles = await import("fs").then(
|
|
2215
|
-
(fsModule) => fsModule.promises.readdir(
|
|
2535
|
+
(fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
|
|
2216
2536
|
);
|
|
2217
2537
|
const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
|
|
2218
2538
|
printDryRun("Would run agent setup (interactive):");
|
|
@@ -2302,6 +2622,22 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2302
2622
|
default: "standard"
|
|
2303
2623
|
});
|
|
2304
2624
|
}
|
|
2625
|
+
let push_policy;
|
|
2626
|
+
if (!options.profile) {
|
|
2627
|
+
console.log();
|
|
2628
|
+
push_policy = await select2({
|
|
2629
|
+
message: "Should this repository be allowed to push to a remote?",
|
|
2630
|
+
choices: [
|
|
2631
|
+
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
2632
|
+
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
2633
|
+
{
|
|
2634
|
+
value: "blocked",
|
|
2635
|
+
name: "No \u2014 block pushes (a pre-push hook plus a disabled push URL)"
|
|
2636
|
+
}
|
|
2637
|
+
],
|
|
2638
|
+
default: "open"
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2305
2641
|
const manifest = {
|
|
2306
2642
|
version: packageVersion,
|
|
2307
2643
|
profile,
|
|
@@ -2310,10 +2646,55 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2310
2646
|
...role ? { role } : {},
|
|
2311
2647
|
...tracking_mode ? { tracking_mode, external_provider } : {},
|
|
2312
2648
|
...subject_root !== void 0 ? { subject_root } : {},
|
|
2313
|
-
...content_sensitivity ? { content_sensitivity } : {}
|
|
2649
|
+
...content_sensitivity ? { content_sensitivity } : {},
|
|
2650
|
+
...push_policy ? { push_policy } : {}
|
|
2314
2651
|
};
|
|
2315
2652
|
await writeManifest(targetDir, manifest);
|
|
2316
2653
|
printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
|
|
2654
|
+
if (push_policy === "blocked" || push_policy === "guarded") {
|
|
2655
|
+
try {
|
|
2656
|
+
if (push_policy === "blocked") {
|
|
2657
|
+
const hook = await installPrePushHook(targetDir);
|
|
2658
|
+
if (hook.status === "refused-unreadable-config") {
|
|
2659
|
+
printWarning(
|
|
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.`
|
|
2665
|
+
);
|
|
2666
|
+
} else {
|
|
2667
|
+
printInfo("Installed a pre-push hook that blocks pushes from this repository.");
|
|
2668
|
+
}
|
|
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
|
+
}
|
|
2676
|
+
if (urls.length === 0) {
|
|
2677
|
+
printWarning(
|
|
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."
|
|
2679
|
+
);
|
|
2680
|
+
} else {
|
|
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
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
2687
|
+
} else {
|
|
2688
|
+
printInfo(
|
|
2689
|
+
"Marked as push-guarded. No technical control was installed \u2014 check the remote is the one you intend before pushing. `spell doctor` will keep reminding you."
|
|
2690
|
+
);
|
|
2691
|
+
}
|
|
2692
|
+
} catch (err) {
|
|
2693
|
+
printWarning(
|
|
2694
|
+
`Could not fully apply push-safety controls: ${err instanceof Error ? err.message : String(err)}. The manifest records push_policy: "${push_policy}", but this repository may not actually be protected. Run \`spell doctor\` to see exactly what is and isn't in place.`
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2317
2698
|
let agentsConfigured = false;
|
|
2318
2699
|
if (hasAgents && !options.profile) {
|
|
2319
2700
|
console.log();
|
|
@@ -2359,7 +2740,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2359
2740
|
}
|
|
2360
2741
|
|
|
2361
2742
|
// src/commands/add.ts
|
|
2362
|
-
import { join as
|
|
2743
|
+
import { join as join6 } from "path";
|
|
2363
2744
|
async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
2364
2745
|
let manifest;
|
|
2365
2746
|
try {
|
|
@@ -2394,7 +2775,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2394
2775
|
let fileCount = 0;
|
|
2395
2776
|
const installedFiles = [];
|
|
2396
2777
|
for (const file of component.files) {
|
|
2397
|
-
const srcPath =
|
|
2778
|
+
const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2398
2779
|
if (options.dryRun) {
|
|
2399
2780
|
console.log(` [dry-run] Would copy: ${file}`);
|
|
2400
2781
|
} else {
|
|
@@ -2404,7 +2785,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2404
2785
|
fileCount++;
|
|
2405
2786
|
}
|
|
2406
2787
|
for (const dir of component.directories ?? []) {
|
|
2407
|
-
const srcDirPath =
|
|
2788
|
+
const srcDirPath = join6(assetsDir, dir);
|
|
2408
2789
|
if (options.dryRun) {
|
|
2409
2790
|
console.log(` [dry-run] Would copy directory: ${dir}/`);
|
|
2410
2791
|
} else {
|
|
@@ -2434,12 +2815,12 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2434
2815
|
}
|
|
2435
2816
|
|
|
2436
2817
|
// src/commands/update.ts
|
|
2437
|
-
import { join as
|
|
2818
|
+
import { join as join8 } from "path";
|
|
2438
2819
|
|
|
2439
2820
|
// src/modules/hub.ts
|
|
2440
2821
|
import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
|
|
2441
|
-
import { readdir as readdir3, writeFile as
|
|
2442
|
-
import { join as
|
|
2822
|
+
import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
|
|
2823
|
+
import { join as join7 } from "path";
|
|
2443
2824
|
var MANIFEST_RETROFITS = [
|
|
2444
2825
|
{
|
|
2445
2826
|
field: "role",
|
|
@@ -2539,6 +2920,27 @@ var MANIFEST_RETROFITS = [
|
|
|
2539
2920
|
}
|
|
2540
2921
|
return { subject_root: null };
|
|
2541
2922
|
}
|
|
2923
|
+
},
|
|
2924
|
+
{
|
|
2925
|
+
field: "push_policy",
|
|
2926
|
+
needsRetrofit: (m) => m.push_policy === void 0,
|
|
2927
|
+
// EF-09. Defaults to "open", so an operator who just presses enter keeps
|
|
2928
|
+
// today's behaviour exactly. NOTE: this only records the choice -- unlike
|
|
2929
|
+
// init, the retrofit does not install the hook or disable the push URL,
|
|
2930
|
+
// because an update should not silently start blocking pushes in a repo
|
|
2931
|
+
// someone is mid-workflow in. doctor reports the gap instead.
|
|
2932
|
+
ask: async () => {
|
|
2933
|
+
const push_policy = await select3({
|
|
2934
|
+
message: "Should this repository be allowed to push to a remote?",
|
|
2935
|
+
choices: [
|
|
2936
|
+
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
2937
|
+
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
2938
|
+
{ value: "blocked", name: "No \u2014 block pushes" }
|
|
2939
|
+
],
|
|
2940
|
+
default: "open"
|
|
2941
|
+
});
|
|
2942
|
+
return { push_policy };
|
|
2943
|
+
}
|
|
2542
2944
|
}
|
|
2543
2945
|
];
|
|
2544
2946
|
async function runManifestRetrofits(manifest) {
|
|
@@ -2556,9 +2958,9 @@ async function runManifestRetrofits(manifest) {
|
|
|
2556
2958
|
return patch;
|
|
2557
2959
|
}
|
|
2558
2960
|
async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
2559
|
-
const registryPath =
|
|
2961
|
+
const registryPath = join7(targetDir, businessRoot, "registry.json");
|
|
2560
2962
|
if (await fileExists(registryPath)) return;
|
|
2561
|
-
const businessRootPath =
|
|
2963
|
+
const businessRootPath = join7(targetDir, businessRoot);
|
|
2562
2964
|
let ventureDirs;
|
|
2563
2965
|
try {
|
|
2564
2966
|
const entries = await readdir3(businessRootPath, { withFileTypes: true });
|
|
@@ -2596,7 +2998,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
|
2596
2998
|
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
2597
2999
|
ventures
|
|
2598
3000
|
};
|
|
2599
|
-
await
|
|
3001
|
+
await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
|
|
2600
3002
|
console.log(
|
|
2601
3003
|
` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
|
|
2602
3004
|
);
|
|
@@ -2689,8 +3091,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2689
3091
|
}
|
|
2690
3092
|
const updatedFiles = [];
|
|
2691
3093
|
for (const file of component.files) {
|
|
2692
|
-
const srcPath =
|
|
2693
|
-
const targetExists = await fileExists(
|
|
3094
|
+
const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
3095
|
+
const targetExists = await fileExists(join8(targetDir, file));
|
|
2694
3096
|
const preserveExisting = Boolean(component.skipExisting) && targetExists;
|
|
2695
3097
|
if (component.initOnly && !targetExists) {
|
|
2696
3098
|
console.log(
|
|
@@ -2712,7 +3114,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2712
3114
|
}
|
|
2713
3115
|
}
|
|
2714
3116
|
for (const dir of component.directories ?? []) {
|
|
2715
|
-
const srcDirPath =
|
|
3117
|
+
const srcDirPath = join8(assetsDir, dir);
|
|
2716
3118
|
if (options.dryRun) {
|
|
2717
3119
|
console.log(` [dry-run] Would update directory: ${dir}/`);
|
|
2718
3120
|
} else {
|
|
@@ -2767,8 +3169,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2767
3169
|
}
|
|
2768
3170
|
|
|
2769
3171
|
// src/modules/version-check.ts
|
|
2770
|
-
import { readFile as
|
|
2771
|
-
import { dirname, join as
|
|
3172
|
+
import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
|
|
3173
|
+
import { dirname, join as join9 } from "path";
|
|
2772
3174
|
import { homedir as homedir2 } from "os";
|
|
2773
3175
|
import { createRequire } from "module";
|
|
2774
3176
|
var require2 = createRequire(import.meta.url);
|
|
@@ -2782,11 +3184,11 @@ function getFeedUrl() {
|
|
|
2782
3184
|
}
|
|
2783
3185
|
return url;
|
|
2784
3186
|
}
|
|
2785
|
-
var DEFAULT_CACHE_PATH =
|
|
3187
|
+
var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
|
|
2786
3188
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2787
3189
|
async function readCache(cachePath) {
|
|
2788
3190
|
try {
|
|
2789
|
-
const content = await
|
|
3191
|
+
const content = await readFile5(cachePath, "utf-8");
|
|
2790
3192
|
const parsed = JSON.parse(content);
|
|
2791
3193
|
if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
|
|
2792
3194
|
return null;
|
|
@@ -2797,9 +3199,9 @@ async function readCache(cachePath) {
|
|
|
2797
3199
|
}
|
|
2798
3200
|
}
|
|
2799
3201
|
async function writeCache(cachePath, latest) {
|
|
2800
|
-
await
|
|
3202
|
+
await mkdir5(dirname(cachePath), { recursive: true });
|
|
2801
3203
|
const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2802
|
-
await
|
|
3204
|
+
await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
|
|
2803
3205
|
}
|
|
2804
3206
|
async function getLatestVersion(feedUrl) {
|
|
2805
3207
|
const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
|
|
@@ -2911,8 +3313,8 @@ async function runStatus(targetDir, packageVersion) {
|
|
|
2911
3313
|
}
|
|
2912
3314
|
|
|
2913
3315
|
// src/commands/uninstall.ts
|
|
2914
|
-
import { rm as
|
|
2915
|
-
import { join as
|
|
3316
|
+
import { rm as rm3 } from "fs/promises";
|
|
3317
|
+
import { join as join10 } from "path";
|
|
2916
3318
|
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2917
3319
|
async function runUninstall(options, targetDir) {
|
|
2918
3320
|
let manifest;
|
|
@@ -2926,6 +3328,21 @@ async function runUninstall(options, targetDir) {
|
|
|
2926
3328
|
}
|
|
2927
3329
|
throw err;
|
|
2928
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
|
+
}
|
|
2929
3346
|
const allFiles = manifest.components.flatMap((c) => c.files);
|
|
2930
3347
|
const totalFiles = allFiles.length;
|
|
2931
3348
|
if (options.dryRun) {
|
|
@@ -2940,6 +3357,11 @@ async function runUninstall(options, targetDir) {
|
|
|
2940
3357
|
console.log(`[dry-run] Would strip arcane sections from: CLAUDE.md, AGENTS.md, copilot-instructions.md`);
|
|
2941
3358
|
console.log(`
|
|
2942
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
|
+
}
|
|
2943
3365
|
return;
|
|
2944
3366
|
}
|
|
2945
3367
|
if (!options.yes) {
|
|
@@ -2955,24 +3377,24 @@ async function runUninstall(options, targetDir) {
|
|
|
2955
3377
|
let removed = 0;
|
|
2956
3378
|
for (const component of manifest.components) {
|
|
2957
3379
|
for (const file of component.files) {
|
|
2958
|
-
const filePath =
|
|
3380
|
+
const filePath = join10(targetDir, file);
|
|
2959
3381
|
try {
|
|
2960
|
-
await
|
|
3382
|
+
await rm3(filePath, { force: true });
|
|
2961
3383
|
removed++;
|
|
2962
3384
|
} catch {
|
|
2963
3385
|
}
|
|
2964
3386
|
}
|
|
2965
3387
|
}
|
|
2966
|
-
await
|
|
3388
|
+
await rm3(join10(targetDir, ".arcane.json"), { force: true });
|
|
2967
3389
|
const agentDirs = [
|
|
2968
3390
|
".github/agents",
|
|
2969
3391
|
".arcane/agents",
|
|
2970
3392
|
".arcane/generated"
|
|
2971
3393
|
];
|
|
2972
3394
|
for (const dir of agentDirs) {
|
|
2973
|
-
await
|
|
3395
|
+
await rm3(join10(targetDir, dir), { recursive: true, force: true });
|
|
2974
3396
|
}
|
|
2975
|
-
await
|
|
3397
|
+
await rm3(join10(targetDir, ".arcane/agents.yaml"), { force: true });
|
|
2976
3398
|
const markerFiles = [
|
|
2977
3399
|
"CLAUDE.md",
|
|
2978
3400
|
"AGENTS.md",
|
|
@@ -2986,8 +3408,8 @@ async function runUninstall(options, targetDir) {
|
|
|
2986
3408
|
}
|
|
2987
3409
|
|
|
2988
3410
|
// src/commands/doctor.ts
|
|
2989
|
-
import { access as
|
|
2990
|
-
import { join as
|
|
3411
|
+
import { access as access4, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
|
|
3412
|
+
import { join as join11, dirname as dirname2 } from "path";
|
|
2991
3413
|
import { execFile as execFile2 } from "child_process";
|
|
2992
3414
|
import { promisify } from "util";
|
|
2993
3415
|
|
|
@@ -3075,8 +3497,8 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3075
3497
|
const name = `VS Code extension: ${label}`;
|
|
3076
3498
|
const home = process.env["HOME"] ?? "";
|
|
3077
3499
|
const extDirs = [
|
|
3078
|
-
|
|
3079
|
-
|
|
3500
|
+
join11(home, ".vscode", "extensions"),
|
|
3501
|
+
join11(home, ".vscode-insiders", "extensions")
|
|
3080
3502
|
];
|
|
3081
3503
|
for (const dir of extDirs) {
|
|
3082
3504
|
try {
|
|
@@ -3110,13 +3532,13 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3110
3532
|
}
|
|
3111
3533
|
async function checkArcaneManifest(targetDir) {
|
|
3112
3534
|
const name = "Arcane manifest (.arcane.json)";
|
|
3113
|
-
const manifestPath2 =
|
|
3535
|
+
const manifestPath2 = join11(targetDir, ".arcane.json");
|
|
3114
3536
|
try {
|
|
3115
|
-
await
|
|
3537
|
+
await access4(manifestPath2);
|
|
3116
3538
|
} catch {
|
|
3117
|
-
const selfHostedManifestPath =
|
|
3539
|
+
const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
|
|
3118
3540
|
try {
|
|
3119
|
-
const raw = await
|
|
3541
|
+
const raw = await readFile6(selfHostedManifestPath, "utf8");
|
|
3120
3542
|
const manifest = JSON.parse(raw);
|
|
3121
3543
|
if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
|
|
3122
3544
|
return {
|
|
@@ -3135,7 +3557,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
3135
3557
|
};
|
|
3136
3558
|
}
|
|
3137
3559
|
try {
|
|
3138
|
-
const raw = await
|
|
3560
|
+
const raw = await readFile6(manifestPath2, "utf8");
|
|
3139
3561
|
const manifest = JSON.parse(raw);
|
|
3140
3562
|
if (!manifest.version || !Array.isArray(manifest.components)) {
|
|
3141
3563
|
return {
|
|
@@ -3206,9 +3628,9 @@ async function checkSessionContinuity(targetDir) {
|
|
|
3206
3628
|
const results = [];
|
|
3207
3629
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
3208
3630
|
const name = `Session file: ${file}`;
|
|
3209
|
-
const filePath =
|
|
3631
|
+
const filePath = join11(targetDir, file);
|
|
3210
3632
|
try {
|
|
3211
|
-
await
|
|
3633
|
+
await access4(filePath);
|
|
3212
3634
|
results.push({ name, passed: true, message: "present" });
|
|
3213
3635
|
} catch {
|
|
3214
3636
|
results.push({
|
|
@@ -3224,18 +3646,69 @@ async function checkSessionContinuity(targetDir) {
|
|
|
3224
3646
|
async function fixSessionContinuity(targetDir, assetsDir) {
|
|
3225
3647
|
const created = [];
|
|
3226
3648
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
3227
|
-
const destPath =
|
|
3649
|
+
const destPath = join11(targetDir, file);
|
|
3228
3650
|
try {
|
|
3229
|
-
await
|
|
3651
|
+
await access4(destPath);
|
|
3230
3652
|
} catch {
|
|
3231
|
-
const srcPath =
|
|
3232
|
-
await
|
|
3653
|
+
const srcPath = join11(assetsDir, file);
|
|
3654
|
+
await mkdir6(dirname2(destPath), { recursive: true });
|
|
3233
3655
|
await fsCopyFile2(srcPath, destPath);
|
|
3234
3656
|
created.push(file);
|
|
3235
3657
|
}
|
|
3236
3658
|
}
|
|
3237
3659
|
return created;
|
|
3238
3660
|
}
|
|
3661
|
+
async function checkPushPolicy(targetDir) {
|
|
3662
|
+
const name = "Push policy (EF-09)";
|
|
3663
|
+
let manifest;
|
|
3664
|
+
try {
|
|
3665
|
+
manifest = await readManifest(targetDir);
|
|
3666
|
+
} catch {
|
|
3667
|
+
return { name, passed: true, message: "not initialized \u2014 nothing to check" };
|
|
3668
|
+
}
|
|
3669
|
+
const policy = manifest.push_policy ?? "open";
|
|
3670
|
+
if (policy === "open") {
|
|
3671
|
+
return { name, passed: true, message: "open \u2014 pushes allowed" };
|
|
3672
|
+
}
|
|
3673
|
+
if (policy === "guarded") {
|
|
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("; ");
|
|
3676
|
+
return {
|
|
3677
|
+
name,
|
|
3678
|
+
passed: false,
|
|
3679
|
+
blocking: false,
|
|
3680
|
+
message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
const hookActive = await isHookEnforced(targetDir);
|
|
3684
|
+
const remotes = await listRemotes(targetDir);
|
|
3685
|
+
const open = await undisabledRemotes(targetDir);
|
|
3686
|
+
if (hookActive && open.length === 0) {
|
|
3687
|
+
if (remotes.length === 0) {
|
|
3688
|
+
return {
|
|
3689
|
+
name,
|
|
3690
|
+
passed: false,
|
|
3691
|
+
blocking: false,
|
|
3692
|
+
message: "blocked \u2014 pre-push hook in place, but no remote is configured, so no push URL is disabled. If a remote is added later, run `spell doctor` again: a --no-verify push to it would succeed until the URL is disabled too."
|
|
3693
|
+
};
|
|
3694
|
+
}
|
|
3695
|
+
return {
|
|
3696
|
+
name,
|
|
3697
|
+
passed: true,
|
|
3698
|
+
message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
|
|
3699
|
+
};
|
|
3700
|
+
}
|
|
3701
|
+
const missing = [
|
|
3702
|
+
hookActive ? null : "pre-push hook (config or hook file absent)",
|
|
3703
|
+
open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
|
|
3704
|
+
].filter(Boolean);
|
|
3705
|
+
return {
|
|
3706
|
+
name,
|
|
3707
|
+
passed: false,
|
|
3708
|
+
blocking: false,
|
|
3709
|
+
message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
|
|
3710
|
+
};
|
|
3711
|
+
}
|
|
3239
3712
|
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
3240
3713
|
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
3241
3714
|
const results = await Promise.all([
|
|
@@ -3243,7 +3716,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
3243
3716
|
checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
|
|
3244
3717
|
checkArcaneManifest(targetDir),
|
|
3245
3718
|
Promise.resolve(checkIncidentReleaseGate()),
|
|
3246
|
-
checkPullRebase(targetDir)
|
|
3719
|
+
checkPullRebase(targetDir),
|
|
3720
|
+
checkPushPolicy(targetDir)
|
|
3247
3721
|
]);
|
|
3248
3722
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
3249
3723
|
results.push(...sessionResults);
|
|
@@ -3287,11 +3761,103 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
3287
3761
|
}
|
|
3288
3762
|
}
|
|
3289
3763
|
|
|
3764
|
+
// src/commands/unblock-push.ts
|
|
3765
|
+
import { basename, resolve as resolve2 } from "path";
|
|
3766
|
+
import { input as input4 } from "@inquirer/prompts";
|
|
3767
|
+
async function runUnblockPush(targetDir) {
|
|
3768
|
+
let manifest;
|
|
3769
|
+
try {
|
|
3770
|
+
manifest = await readManifest(targetDir);
|
|
3771
|
+
} catch (err) {
|
|
3772
|
+
if (err instanceof ManifestNotFoundError) {
|
|
3773
|
+
console.error('Not initialized. Run "spell init" first.');
|
|
3774
|
+
process.exit(1);
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
throw err;
|
|
3778
|
+
}
|
|
3779
|
+
const policy = manifest.push_policy ?? "open";
|
|
3780
|
+
if (policy === "open") {
|
|
3781
|
+
printInfo("This repository is not push-blocked \u2014 nothing to undo.");
|
|
3782
|
+
return;
|
|
3783
|
+
}
|
|
3784
|
+
if (!process.stdin.isTTY) {
|
|
3785
|
+
console.error(
|
|
3786
|
+
"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."
|
|
3787
|
+
);
|
|
3788
|
+
process.exit(1);
|
|
3789
|
+
return;
|
|
3790
|
+
}
|
|
3791
|
+
const repoName = basename(resolve2(targetDir));
|
|
3792
|
+
if (repoName === "") {
|
|
3793
|
+
console.error(
|
|
3794
|
+
"Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
|
|
3795
|
+
);
|
|
3796
|
+
process.exit(1);
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
printWarning(`This repository's push_policy is "${policy}".`);
|
|
3800
|
+
printInfo(
|
|
3801
|
+
"Unblocking restores the ability to push this repository's full history to its remote. Deleting a file later does not undo a push: the content stays in history, in any clone already made, and in the remote's own backups."
|
|
3802
|
+
);
|
|
3803
|
+
console.log();
|
|
3804
|
+
const typed = await input4({
|
|
3805
|
+
message: `Type the repository name to confirm (${repoName}):`
|
|
3806
|
+
});
|
|
3807
|
+
if (typed.trim() !== repoName) {
|
|
3808
|
+
console.error(`
|
|
3809
|
+
Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
3810
|
+
process.exit(1);
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
await removePrePushHook(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);
|
|
3818
|
+
const updated = {
|
|
3819
|
+
...manifest,
|
|
3820
|
+
push_policy: "open",
|
|
3821
|
+
push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3822
|
+
};
|
|
3823
|
+
await writeManifest(targetDir, updated);
|
|
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
|
+
}
|
|
3833
|
+
if (restored.length > 0) {
|
|
3834
|
+
printInfo(`Restored push URL for: ${restored.join(", ")}.`);
|
|
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
|
+
}
|
|
3851
|
+
printInfo(
|
|
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."
|
|
3853
|
+
);
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3290
3856
|
// src/index.ts
|
|
3291
3857
|
import { realpathSync } from "fs";
|
|
3292
3858
|
var require3 = createRequire2(import.meta.url);
|
|
3293
3859
|
var pkg = require3("../package.json");
|
|
3294
|
-
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ??
|
|
3860
|
+
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname3(fileURLToPath(import.meta.url)), "assets");
|
|
3295
3861
|
var program = new Command();
|
|
3296
3862
|
program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
|
|
3297
3863
|
await printWelcome(pkg.version, process.cwd());
|
|
@@ -3336,6 +3902,9 @@ program.command("status").description("Show installed components and check for u
|
|
|
3336
3902
|
program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").action(async (opts) => {
|
|
3337
3903
|
await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
|
|
3338
3904
|
});
|
|
3905
|
+
program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
|
|
3906
|
+
await runUnblockPush(process.cwd());
|
|
3907
|
+
});
|
|
3339
3908
|
program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
|
|
3340
3909
|
await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
|
|
3341
3910
|
});
|