arcane-cli 0.19.0 → 0.20.0
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 +386 -46
- 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((resolve2) => setTimeout(resolve2, 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,10 @@ 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 } from "fs/promises";
|
|
1799
|
+
import { join as join4 } from "path";
|
|
1800
|
+
|
|
1793
1801
|
// src/modules/git.ts
|
|
1794
1802
|
import { execFile } from "child_process";
|
|
1795
1803
|
var GitTimeoutError = class extends Error {
|
|
@@ -1885,7 +1893,7 @@ function buildGitEnv() {
|
|
|
1885
1893
|
function runGit(cwd, args, options = {}) {
|
|
1886
1894
|
const commandClass = options.commandClass ?? classifyGitCommand(args);
|
|
1887
1895
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
|
|
1888
|
-
return new Promise((
|
|
1896
|
+
return new Promise((resolve2, reject) => {
|
|
1889
1897
|
const child = execFile(
|
|
1890
1898
|
"git",
|
|
1891
1899
|
args,
|
|
@@ -1919,7 +1927,7 @@ function runGit(cwd, args, options = {}) {
|
|
|
1919
1927
|
reject(error);
|
|
1920
1928
|
return;
|
|
1921
1929
|
}
|
|
1922
|
-
|
|
1930
|
+
resolve2({ stdout, stderr });
|
|
1923
1931
|
}
|
|
1924
1932
|
);
|
|
1925
1933
|
child.stdin?.end();
|
|
@@ -2008,6 +2016,146 @@ async function ensureLocalPullRebase(cwd) {
|
|
|
2008
2016
|
}
|
|
2009
2017
|
}
|
|
2010
2018
|
|
|
2019
|
+
// src/modules/push-safety.ts
|
|
2020
|
+
var DISABLED_PUSH_URL = "arcane-push-blocked://this-repository-is-push-blocked";
|
|
2021
|
+
var ARCANE_HOOKS_DIR = ".arcane/hooks";
|
|
2022
|
+
async function readHooksPath(cwd) {
|
|
2023
|
+
let effective;
|
|
2024
|
+
try {
|
|
2025
|
+
const { stdout } = await runGit(cwd, ["config", "--get", "core.hooksPath"]);
|
|
2026
|
+
effective = stdout.trim();
|
|
2027
|
+
} catch {
|
|
2028
|
+
return void 0;
|
|
2029
|
+
}
|
|
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" };
|
|
2038
|
+
}
|
|
2039
|
+
var HOOK_BODY = `#!/bin/sh
|
|
2040
|
+
# Installed by Arcane because this repository's .arcane.json sets
|
|
2041
|
+
# push_policy: "blocked".
|
|
2042
|
+
#
|
|
2043
|
+
# This is deliberately not tamper-proof. It guards against an accidental push
|
|
2044
|
+
# -- the wrong remote, muscle memory, an unsupervised agent -- not against a
|
|
2045
|
+
# determined operator, who can always bypass it.
|
|
2046
|
+
#
|
|
2047
|
+
# To push legitimately, undo the policy first:
|
|
2048
|
+
#
|
|
2049
|
+
# spell unblock-push
|
|
2050
|
+
#
|
|
2051
|
+
# That command is interactive-only and asks you to confirm the repository by
|
|
2052
|
+
# name. Do not work around this hook by other means: the manifest would then
|
|
2053
|
+
# claim a protection this repository no longer has.
|
|
2054
|
+
echo "arcane: push blocked (push_policy: blocked). Run 'spell unblock-push' to undo." >&2
|
|
2055
|
+
exit 1
|
|
2056
|
+
`;
|
|
2057
|
+
function hookFilePath(cwd) {
|
|
2058
|
+
return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
|
|
2059
|
+
}
|
|
2060
|
+
async function installPrePushHook(cwd) {
|
|
2061
|
+
const existing = await readHooksPath(cwd);
|
|
2062
|
+
if (existing !== void 0 && existing.value !== ARCANE_HOOKS_DIR) {
|
|
2063
|
+
return {
|
|
2064
|
+
status: "refused-foreign-hooks-path",
|
|
2065
|
+
existing: existing.value,
|
|
2066
|
+
scope: existing.scope
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
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" };
|
|
2073
|
+
}
|
|
2074
|
+
await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
|
|
2075
|
+
await writeFile5(hookPath, HOOK_BODY, "utf-8");
|
|
2076
|
+
await chmod(hookPath, 493);
|
|
2077
|
+
await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
|
|
2078
|
+
return { status: "installed", path: hookPath };
|
|
2079
|
+
}
|
|
2080
|
+
async function isHookEnforced(cwd) {
|
|
2081
|
+
const hooksPath = await readHooksPath(cwd);
|
|
2082
|
+
if (hooksPath?.value !== ARCANE_HOOKS_DIR) return false;
|
|
2083
|
+
return fileExists(hookFilePath(cwd));
|
|
2084
|
+
}
|
|
2085
|
+
async function removePrePushHook(cwd) {
|
|
2086
|
+
const existing = await readHooksPath(cwd);
|
|
2087
|
+
if (existing?.value !== ARCANE_HOOKS_DIR || existing.scope !== "local") return;
|
|
2088
|
+
try {
|
|
2089
|
+
await runGit(cwd, ["config", "--local", "--unset", "core.hooksPath"]);
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
async function listRemotes(cwd) {
|
|
2094
|
+
try {
|
|
2095
|
+
const { stdout } = await runGit(cwd, ["remote"]);
|
|
2096
|
+
return stdout.split("\n").map((r) => r.trim()).filter(Boolean);
|
|
2097
|
+
} catch {
|
|
2098
|
+
return [];
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function disablePushUrls(cwd) {
|
|
2102
|
+
const results = [];
|
|
2103
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2104
|
+
let current;
|
|
2105
|
+
try {
|
|
2106
|
+
const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
|
|
2107
|
+
current = stdout.trim();
|
|
2108
|
+
} catch {
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
if (current === DISABLED_PUSH_URL) {
|
|
2112
|
+
results.push({ remote, status: "already-disabled" });
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
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
|
+
}
|
|
2119
|
+
return results;
|
|
2120
|
+
}
|
|
2121
|
+
async function undisabledRemotes(cwd) {
|
|
2122
|
+
const open = [];
|
|
2123
|
+
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
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
return open;
|
|
2132
|
+
}
|
|
2133
|
+
async function restorePushUrls(cwd) {
|
|
2134
|
+
const restored = [];
|
|
2135
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2136
|
+
let original = "";
|
|
2137
|
+
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;
|
|
2147
|
+
}
|
|
2148
|
+
if (original === "") continue;
|
|
2149
|
+
await runGit(cwd, ["remote", "set-url", "--push", remote, original]);
|
|
2150
|
+
try {
|
|
2151
|
+
await runGit(cwd, ["config", "--local", "--unset", `arcane.originalPushUrl.${remote}`]);
|
|
2152
|
+
} catch {
|
|
2153
|
+
}
|
|
2154
|
+
restored.push(remote);
|
|
2155
|
+
}
|
|
2156
|
+
return restored;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2011
2159
|
// src/commands/init.ts
|
|
2012
2160
|
var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
|
|
2013
2161
|
var PROFILE_ICONS = {
|
|
@@ -2170,7 +2318,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2170
2318
|
for (const component of components) {
|
|
2171
2319
|
const installedFiles = [];
|
|
2172
2320
|
for (const file of component.files) {
|
|
2173
|
-
const srcPath =
|
|
2321
|
+
const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2174
2322
|
if (options.dryRun) {
|
|
2175
2323
|
printDryRun(`Would copy: ${file}`);
|
|
2176
2324
|
} else {
|
|
@@ -2187,7 +2335,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2187
2335
|
fileCount++;
|
|
2188
2336
|
}
|
|
2189
2337
|
for (const dir of component.directories ?? []) {
|
|
2190
|
-
const srcDirPath =
|
|
2338
|
+
const srcDirPath = join5(assetsDir, dir);
|
|
2191
2339
|
if (options.dryRun) {
|
|
2192
2340
|
printDryRun(`Would copy directory: ${dir}/`);
|
|
2193
2341
|
} else {
|
|
@@ -2212,7 +2360,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2212
2360
|
if (options.dryRun) {
|
|
2213
2361
|
if (hasAgents) {
|
|
2214
2362
|
const agentFiles = await import("fs").then(
|
|
2215
|
-
(fsModule) => fsModule.promises.readdir(
|
|
2363
|
+
(fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
|
|
2216
2364
|
);
|
|
2217
2365
|
const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
|
|
2218
2366
|
printDryRun("Would run agent setup (interactive):");
|
|
@@ -2302,6 +2450,22 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2302
2450
|
default: "standard"
|
|
2303
2451
|
});
|
|
2304
2452
|
}
|
|
2453
|
+
let push_policy;
|
|
2454
|
+
if (!options.profile) {
|
|
2455
|
+
console.log();
|
|
2456
|
+
push_policy = await select2({
|
|
2457
|
+
message: "Should this repository be allowed to push to a remote?",
|
|
2458
|
+
choices: [
|
|
2459
|
+
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
2460
|
+
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
2461
|
+
{
|
|
2462
|
+
value: "blocked",
|
|
2463
|
+
name: "No \u2014 block pushes (a pre-push hook plus a disabled push URL)"
|
|
2464
|
+
}
|
|
2465
|
+
],
|
|
2466
|
+
default: "open"
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2305
2469
|
const manifest = {
|
|
2306
2470
|
version: packageVersion,
|
|
2307
2471
|
profile,
|
|
@@ -2310,10 +2474,44 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2310
2474
|
...role ? { role } : {},
|
|
2311
2475
|
...tracking_mode ? { tracking_mode, external_provider } : {},
|
|
2312
2476
|
...subject_root !== void 0 ? { subject_root } : {},
|
|
2313
|
-
...content_sensitivity ? { content_sensitivity } : {}
|
|
2477
|
+
...content_sensitivity ? { content_sensitivity } : {},
|
|
2478
|
+
...push_policy ? { push_policy } : {}
|
|
2314
2479
|
};
|
|
2315
2480
|
await writeManifest(targetDir, manifest);
|
|
2316
2481
|
printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
|
|
2482
|
+
if (push_policy === "blocked" || push_policy === "guarded") {
|
|
2483
|
+
try {
|
|
2484
|
+
if (push_policy === "blocked") {
|
|
2485
|
+
const hook = await installPrePushHook(targetDir);
|
|
2486
|
+
if (hook.status === "refused-foreign-hooks-path") {
|
|
2487
|
+
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.`
|
|
2489
|
+
);
|
|
2490
|
+
} else {
|
|
2491
|
+
printInfo("Installed a pre-push hook that blocks pushes from this repository.");
|
|
2492
|
+
}
|
|
2493
|
+
const urls = await disablePushUrls(targetDir);
|
|
2494
|
+
if (urls.length === 0) {
|
|
2495
|
+
printWarning(
|
|
2496
|
+
"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
|
+
);
|
|
2498
|
+
} else {
|
|
2499
|
+
printInfo(
|
|
2500
|
+
`Disabled the push URL for: ${urls.map((u) => u.remote).join(", ")} (fetch still works).`
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
2504
|
+
} else {
|
|
2505
|
+
printInfo(
|
|
2506
|
+
"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."
|
|
2507
|
+
);
|
|
2508
|
+
}
|
|
2509
|
+
} catch (err) {
|
|
2510
|
+
printWarning(
|
|
2511
|
+
`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.`
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2317
2515
|
let agentsConfigured = false;
|
|
2318
2516
|
if (hasAgents && !options.profile) {
|
|
2319
2517
|
console.log();
|
|
@@ -2359,7 +2557,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2359
2557
|
}
|
|
2360
2558
|
|
|
2361
2559
|
// src/commands/add.ts
|
|
2362
|
-
import { join as
|
|
2560
|
+
import { join as join6 } from "path";
|
|
2363
2561
|
async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
2364
2562
|
let manifest;
|
|
2365
2563
|
try {
|
|
@@ -2394,7 +2592,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2394
2592
|
let fileCount = 0;
|
|
2395
2593
|
const installedFiles = [];
|
|
2396
2594
|
for (const file of component.files) {
|
|
2397
|
-
const srcPath =
|
|
2595
|
+
const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2398
2596
|
if (options.dryRun) {
|
|
2399
2597
|
console.log(` [dry-run] Would copy: ${file}`);
|
|
2400
2598
|
} else {
|
|
@@ -2404,7 +2602,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2404
2602
|
fileCount++;
|
|
2405
2603
|
}
|
|
2406
2604
|
for (const dir of component.directories ?? []) {
|
|
2407
|
-
const srcDirPath =
|
|
2605
|
+
const srcDirPath = join6(assetsDir, dir);
|
|
2408
2606
|
if (options.dryRun) {
|
|
2409
2607
|
console.log(` [dry-run] Would copy directory: ${dir}/`);
|
|
2410
2608
|
} else {
|
|
@@ -2434,12 +2632,12 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2434
2632
|
}
|
|
2435
2633
|
|
|
2436
2634
|
// src/commands/update.ts
|
|
2437
|
-
import { join as
|
|
2635
|
+
import { join as join8 } from "path";
|
|
2438
2636
|
|
|
2439
2637
|
// src/modules/hub.ts
|
|
2440
2638
|
import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
|
|
2441
|
-
import { readdir as readdir3, writeFile as
|
|
2442
|
-
import { join as
|
|
2639
|
+
import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
|
|
2640
|
+
import { join as join7 } from "path";
|
|
2443
2641
|
var MANIFEST_RETROFITS = [
|
|
2444
2642
|
{
|
|
2445
2643
|
field: "role",
|
|
@@ -2539,6 +2737,27 @@ var MANIFEST_RETROFITS = [
|
|
|
2539
2737
|
}
|
|
2540
2738
|
return { subject_root: null };
|
|
2541
2739
|
}
|
|
2740
|
+
},
|
|
2741
|
+
{
|
|
2742
|
+
field: "push_policy",
|
|
2743
|
+
needsRetrofit: (m) => m.push_policy === void 0,
|
|
2744
|
+
// EF-09. Defaults to "open", so an operator who just presses enter keeps
|
|
2745
|
+
// today's behaviour exactly. NOTE: this only records the choice -- unlike
|
|
2746
|
+
// init, the retrofit does not install the hook or disable the push URL,
|
|
2747
|
+
// because an update should not silently start blocking pushes in a repo
|
|
2748
|
+
// someone is mid-workflow in. doctor reports the gap instead.
|
|
2749
|
+
ask: async () => {
|
|
2750
|
+
const push_policy = await select3({
|
|
2751
|
+
message: "Should this repository be allowed to push to a remote?",
|
|
2752
|
+
choices: [
|
|
2753
|
+
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
2754
|
+
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
2755
|
+
{ value: "blocked", name: "No \u2014 block pushes" }
|
|
2756
|
+
],
|
|
2757
|
+
default: "open"
|
|
2758
|
+
});
|
|
2759
|
+
return { push_policy };
|
|
2760
|
+
}
|
|
2542
2761
|
}
|
|
2543
2762
|
];
|
|
2544
2763
|
async function runManifestRetrofits(manifest) {
|
|
@@ -2556,9 +2775,9 @@ async function runManifestRetrofits(manifest) {
|
|
|
2556
2775
|
return patch;
|
|
2557
2776
|
}
|
|
2558
2777
|
async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
2559
|
-
const registryPath =
|
|
2778
|
+
const registryPath = join7(targetDir, businessRoot, "registry.json");
|
|
2560
2779
|
if (await fileExists(registryPath)) return;
|
|
2561
|
-
const businessRootPath =
|
|
2780
|
+
const businessRootPath = join7(targetDir, businessRoot);
|
|
2562
2781
|
let ventureDirs;
|
|
2563
2782
|
try {
|
|
2564
2783
|
const entries = await readdir3(businessRootPath, { withFileTypes: true });
|
|
@@ -2596,7 +2815,7 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
|
2596
2815
|
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
2597
2816
|
ventures
|
|
2598
2817
|
};
|
|
2599
|
-
await
|
|
2818
|
+
await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
|
|
2600
2819
|
console.log(
|
|
2601
2820
|
` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
|
|
2602
2821
|
);
|
|
@@ -2689,8 +2908,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2689
2908
|
}
|
|
2690
2909
|
const updatedFiles = [];
|
|
2691
2910
|
for (const file of component.files) {
|
|
2692
|
-
const srcPath =
|
|
2693
|
-
const targetExists = await fileExists(
|
|
2911
|
+
const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2912
|
+
const targetExists = await fileExists(join8(targetDir, file));
|
|
2694
2913
|
const preserveExisting = Boolean(component.skipExisting) && targetExists;
|
|
2695
2914
|
if (component.initOnly && !targetExists) {
|
|
2696
2915
|
console.log(
|
|
@@ -2712,7 +2931,7 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2712
2931
|
}
|
|
2713
2932
|
}
|
|
2714
2933
|
for (const dir of component.directories ?? []) {
|
|
2715
|
-
const srcDirPath =
|
|
2934
|
+
const srcDirPath = join8(assetsDir, dir);
|
|
2716
2935
|
if (options.dryRun) {
|
|
2717
2936
|
console.log(` [dry-run] Would update directory: ${dir}/`);
|
|
2718
2937
|
} else {
|
|
@@ -2767,8 +2986,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2767
2986
|
}
|
|
2768
2987
|
|
|
2769
2988
|
// src/modules/version-check.ts
|
|
2770
|
-
import { readFile as
|
|
2771
|
-
import { dirname, join as
|
|
2989
|
+
import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
|
|
2990
|
+
import { dirname, join as join9 } from "path";
|
|
2772
2991
|
import { homedir as homedir2 } from "os";
|
|
2773
2992
|
import { createRequire } from "module";
|
|
2774
2993
|
var require2 = createRequire(import.meta.url);
|
|
@@ -2782,11 +3001,11 @@ function getFeedUrl() {
|
|
|
2782
3001
|
}
|
|
2783
3002
|
return url;
|
|
2784
3003
|
}
|
|
2785
|
-
var DEFAULT_CACHE_PATH =
|
|
3004
|
+
var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
|
|
2786
3005
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2787
3006
|
async function readCache(cachePath) {
|
|
2788
3007
|
try {
|
|
2789
|
-
const content = await
|
|
3008
|
+
const content = await readFile5(cachePath, "utf-8");
|
|
2790
3009
|
const parsed = JSON.parse(content);
|
|
2791
3010
|
if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
|
|
2792
3011
|
return null;
|
|
@@ -2797,9 +3016,9 @@ async function readCache(cachePath) {
|
|
|
2797
3016
|
}
|
|
2798
3017
|
}
|
|
2799
3018
|
async function writeCache(cachePath, latest) {
|
|
2800
|
-
await
|
|
3019
|
+
await mkdir5(dirname(cachePath), { recursive: true });
|
|
2801
3020
|
const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2802
|
-
await
|
|
3021
|
+
await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
|
|
2803
3022
|
}
|
|
2804
3023
|
async function getLatestVersion(feedUrl) {
|
|
2805
3024
|
const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
|
|
@@ -2912,7 +3131,7 @@ async function runStatus(targetDir, packageVersion) {
|
|
|
2912
3131
|
|
|
2913
3132
|
// src/commands/uninstall.ts
|
|
2914
3133
|
import { rm as rm2 } from "fs/promises";
|
|
2915
|
-
import { join as
|
|
3134
|
+
import { join as join10 } from "path";
|
|
2916
3135
|
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2917
3136
|
async function runUninstall(options, targetDir) {
|
|
2918
3137
|
let manifest;
|
|
@@ -2955,7 +3174,7 @@ async function runUninstall(options, targetDir) {
|
|
|
2955
3174
|
let removed = 0;
|
|
2956
3175
|
for (const component of manifest.components) {
|
|
2957
3176
|
for (const file of component.files) {
|
|
2958
|
-
const filePath =
|
|
3177
|
+
const filePath = join10(targetDir, file);
|
|
2959
3178
|
try {
|
|
2960
3179
|
await rm2(filePath, { force: true });
|
|
2961
3180
|
removed++;
|
|
@@ -2963,16 +3182,16 @@ async function runUninstall(options, targetDir) {
|
|
|
2963
3182
|
}
|
|
2964
3183
|
}
|
|
2965
3184
|
}
|
|
2966
|
-
await rm2(
|
|
3185
|
+
await rm2(join10(targetDir, ".arcane.json"), { force: true });
|
|
2967
3186
|
const agentDirs = [
|
|
2968
3187
|
".github/agents",
|
|
2969
3188
|
".arcane/agents",
|
|
2970
3189
|
".arcane/generated"
|
|
2971
3190
|
];
|
|
2972
3191
|
for (const dir of agentDirs) {
|
|
2973
|
-
await rm2(
|
|
3192
|
+
await rm2(join10(targetDir, dir), { recursive: true, force: true });
|
|
2974
3193
|
}
|
|
2975
|
-
await rm2(
|
|
3194
|
+
await rm2(join10(targetDir, ".arcane/agents.yaml"), { force: true });
|
|
2976
3195
|
const markerFiles = [
|
|
2977
3196
|
"CLAUDE.md",
|
|
2978
3197
|
"AGENTS.md",
|
|
@@ -2986,8 +3205,8 @@ async function runUninstall(options, targetDir) {
|
|
|
2986
3205
|
}
|
|
2987
3206
|
|
|
2988
3207
|
// src/commands/doctor.ts
|
|
2989
|
-
import { access as access3, readFile as
|
|
2990
|
-
import { join as
|
|
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";
|
|
2991
3210
|
import { execFile as execFile2 } from "child_process";
|
|
2992
3211
|
import { promisify } from "util";
|
|
2993
3212
|
|
|
@@ -3075,8 +3294,8 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3075
3294
|
const name = `VS Code extension: ${label}`;
|
|
3076
3295
|
const home = process.env["HOME"] ?? "";
|
|
3077
3296
|
const extDirs = [
|
|
3078
|
-
|
|
3079
|
-
|
|
3297
|
+
join11(home, ".vscode", "extensions"),
|
|
3298
|
+
join11(home, ".vscode-insiders", "extensions")
|
|
3080
3299
|
];
|
|
3081
3300
|
for (const dir of extDirs) {
|
|
3082
3301
|
try {
|
|
@@ -3110,13 +3329,13 @@ async function checkVSCodeExtension(id, label) {
|
|
|
3110
3329
|
}
|
|
3111
3330
|
async function checkArcaneManifest(targetDir) {
|
|
3112
3331
|
const name = "Arcane manifest (.arcane.json)";
|
|
3113
|
-
const manifestPath2 =
|
|
3332
|
+
const manifestPath2 = join11(targetDir, ".arcane.json");
|
|
3114
3333
|
try {
|
|
3115
3334
|
await access3(manifestPath2);
|
|
3116
3335
|
} catch {
|
|
3117
|
-
const selfHostedManifestPath =
|
|
3336
|
+
const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
|
|
3118
3337
|
try {
|
|
3119
|
-
const raw = await
|
|
3338
|
+
const raw = await readFile6(selfHostedManifestPath, "utf8");
|
|
3120
3339
|
const manifest = JSON.parse(raw);
|
|
3121
3340
|
if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
|
|
3122
3341
|
return {
|
|
@@ -3135,7 +3354,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
3135
3354
|
};
|
|
3136
3355
|
}
|
|
3137
3356
|
try {
|
|
3138
|
-
const raw = await
|
|
3357
|
+
const raw = await readFile6(manifestPath2, "utf8");
|
|
3139
3358
|
const manifest = JSON.parse(raw);
|
|
3140
3359
|
if (!manifest.version || !Array.isArray(manifest.components)) {
|
|
3141
3360
|
return {
|
|
@@ -3206,7 +3425,7 @@ async function checkSessionContinuity(targetDir) {
|
|
|
3206
3425
|
const results = [];
|
|
3207
3426
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
3208
3427
|
const name = `Session file: ${file}`;
|
|
3209
|
-
const filePath =
|
|
3428
|
+
const filePath = join11(targetDir, file);
|
|
3210
3429
|
try {
|
|
3211
3430
|
await access3(filePath);
|
|
3212
3431
|
results.push({ name, passed: true, message: "present" });
|
|
@@ -3224,18 +3443,69 @@ async function checkSessionContinuity(targetDir) {
|
|
|
3224
3443
|
async function fixSessionContinuity(targetDir, assetsDir) {
|
|
3225
3444
|
const created = [];
|
|
3226
3445
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
3227
|
-
const destPath =
|
|
3446
|
+
const destPath = join11(targetDir, file);
|
|
3228
3447
|
try {
|
|
3229
3448
|
await access3(destPath);
|
|
3230
3449
|
} catch {
|
|
3231
|
-
const srcPath =
|
|
3232
|
-
await
|
|
3450
|
+
const srcPath = join11(assetsDir, file);
|
|
3451
|
+
await mkdir6(dirname2(destPath), { recursive: true });
|
|
3233
3452
|
await fsCopyFile2(srcPath, destPath);
|
|
3234
3453
|
created.push(file);
|
|
3235
3454
|
}
|
|
3236
3455
|
}
|
|
3237
3456
|
return created;
|
|
3238
3457
|
}
|
|
3458
|
+
async function checkPushPolicy(targetDir) {
|
|
3459
|
+
const name = "Push policy (EF-09)";
|
|
3460
|
+
let manifest;
|
|
3461
|
+
try {
|
|
3462
|
+
manifest = await readManifest(targetDir);
|
|
3463
|
+
} catch {
|
|
3464
|
+
return { name, passed: true, message: "not initialized \u2014 nothing to check" };
|
|
3465
|
+
}
|
|
3466
|
+
const policy = manifest.push_policy ?? "open";
|
|
3467
|
+
if (policy === "open") {
|
|
3468
|
+
return { name, passed: true, message: "open \u2014 pushes allowed" };
|
|
3469
|
+
}
|
|
3470
|
+
if (policy === "guarded") {
|
|
3471
|
+
const remotes2 = await listRemotes(targetDir);
|
|
3472
|
+
const where = remotes2.length === 0 ? "none configured" : remotes2.join(", ");
|
|
3473
|
+
return {
|
|
3474
|
+
name,
|
|
3475
|
+
passed: false,
|
|
3476
|
+
blocking: false,
|
|
3477
|
+
message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
|
|
3478
|
+
};
|
|
3479
|
+
}
|
|
3480
|
+
const hookActive = await isHookEnforced(targetDir);
|
|
3481
|
+
const remotes = await listRemotes(targetDir);
|
|
3482
|
+
const open = await undisabledRemotes(targetDir);
|
|
3483
|
+
if (hookActive && open.length === 0) {
|
|
3484
|
+
if (remotes.length === 0) {
|
|
3485
|
+
return {
|
|
3486
|
+
name,
|
|
3487
|
+
passed: false,
|
|
3488
|
+
blocking: false,
|
|
3489
|
+
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."
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
return {
|
|
3493
|
+
name,
|
|
3494
|
+
passed: true,
|
|
3495
|
+
message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
const missing = [
|
|
3499
|
+
hookActive ? null : "pre-push hook (config or hook file absent)",
|
|
3500
|
+
open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
|
|
3501
|
+
].filter(Boolean);
|
|
3502
|
+
return {
|
|
3503
|
+
name,
|
|
3504
|
+
passed: false,
|
|
3505
|
+
blocking: false,
|
|
3506
|
+
message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
3239
3509
|
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
3240
3510
|
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
3241
3511
|
const results = await Promise.all([
|
|
@@ -3243,7 +3513,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
3243
3513
|
checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
|
|
3244
3514
|
checkArcaneManifest(targetDir),
|
|
3245
3515
|
Promise.resolve(checkIncidentReleaseGate()),
|
|
3246
|
-
checkPullRebase(targetDir)
|
|
3516
|
+
checkPullRebase(targetDir),
|
|
3517
|
+
checkPushPolicy(targetDir)
|
|
3247
3518
|
]);
|
|
3248
3519
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
3249
3520
|
results.push(...sessionResults);
|
|
@@ -3287,11 +3558,77 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
3287
3558
|
}
|
|
3288
3559
|
}
|
|
3289
3560
|
|
|
3561
|
+
// src/commands/unblock-push.ts
|
|
3562
|
+
import { basename, resolve } from "path";
|
|
3563
|
+
import { input as input4 } from "@inquirer/prompts";
|
|
3564
|
+
async function runUnblockPush(targetDir) {
|
|
3565
|
+
let manifest;
|
|
3566
|
+
try {
|
|
3567
|
+
manifest = await readManifest(targetDir);
|
|
3568
|
+
} catch (err) {
|
|
3569
|
+
if (err instanceof ManifestNotFoundError) {
|
|
3570
|
+
console.error('Not initialized. Run "spell init" first.');
|
|
3571
|
+
process.exit(1);
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
throw err;
|
|
3575
|
+
}
|
|
3576
|
+
const policy = manifest.push_policy ?? "open";
|
|
3577
|
+
if (policy === "open") {
|
|
3578
|
+
printInfo("This repository is not push-blocked \u2014 nothing to undo.");
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
if (!process.stdin.isTTY) {
|
|
3582
|
+
console.error(
|
|
3583
|
+
"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."
|
|
3584
|
+
);
|
|
3585
|
+
process.exit(1);
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
const repoName = basename(resolve(targetDir));
|
|
3589
|
+
if (repoName === "") {
|
|
3590
|
+
console.error(
|
|
3591
|
+
"Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
|
|
3592
|
+
);
|
|
3593
|
+
process.exit(1);
|
|
3594
|
+
return;
|
|
3595
|
+
}
|
|
3596
|
+
printWarning(`This repository's push_policy is "${policy}".`);
|
|
3597
|
+
printInfo(
|
|
3598
|
+
"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."
|
|
3599
|
+
);
|
|
3600
|
+
console.log();
|
|
3601
|
+
const typed = await input4({
|
|
3602
|
+
message: `Type the repository name to confirm (${repoName}):`
|
|
3603
|
+
});
|
|
3604
|
+
if (typed.trim() !== repoName) {
|
|
3605
|
+
console.error(`
|
|
3606
|
+
Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
3607
|
+
process.exit(1);
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
await removePrePushHook(targetDir);
|
|
3611
|
+
const restored = await restorePushUrls(targetDir);
|
|
3612
|
+
const updated = {
|
|
3613
|
+
...manifest,
|
|
3614
|
+
push_policy: "open",
|
|
3615
|
+
push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3616
|
+
};
|
|
3617
|
+
await writeManifest(targetDir, updated);
|
|
3618
|
+
printSuccess(`Push unblocked for "${repoName}".`);
|
|
3619
|
+
if (restored.length > 0) {
|
|
3620
|
+
printInfo(`Restored push URL for: ${restored.join(", ")}.`);
|
|
3621
|
+
}
|
|
3622
|
+
printInfo(
|
|
3623
|
+
"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
|
+
);
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3290
3627
|
// src/index.ts
|
|
3291
3628
|
import { realpathSync } from "fs";
|
|
3292
3629
|
var require3 = createRequire2(import.meta.url);
|
|
3293
3630
|
var pkg = require3("../package.json");
|
|
3294
|
-
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ??
|
|
3631
|
+
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname3(fileURLToPath(import.meta.url)), "assets");
|
|
3295
3632
|
var program = new Command();
|
|
3296
3633
|
program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
|
|
3297
3634
|
await printWelcome(pkg.version, process.cwd());
|
|
@@ -3336,6 +3673,9 @@ program.command("status").description("Show installed components and check for u
|
|
|
3336
3673
|
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
3674
|
await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
|
|
3338
3675
|
});
|
|
3676
|
+
program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
|
|
3677
|
+
await runUnblockPush(process.cwd());
|
|
3678
|
+
});
|
|
3339
3679
|
program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
|
|
3340
3680
|
await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
|
|
3341
3681
|
});
|