arcane-cli 1.5.0 → 1.6.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/README.md +37 -0
- package/dist/assets/.arcane/governance/agent-approved-paths.md +1 -1
- package/dist/assets/.arcane/governance/git-conventions.md +25 -3
- package/dist/assets/.arcane/governance/naming-conventions.md +1 -1
- package/dist/assets/.arcane/governance/spell-authoring-standards.md +40 -0
- package/dist/assets/.arcane/governance/universal-agent-rules.md +1 -1
- package/dist/assets/.arcane/spells/_fragments/push-policy-check.md +7 -0
- package/dist/assets/.arcane/spells/spell-address-review.md +15 -3
- package/dist/assets/.arcane/spells/spell-check-drift.md +1 -0
- package/dist/assets/.arcane/spells/spell-close-session.md +12 -0
- package/dist/assets/.arcane/spells/spell-commit-work.md +23 -5
- package/dist/assets/.arcane/spells/spell-create-pull-request.md +19 -4
- package/dist/assets/.arcane/spells/spell-manifest.md +7 -3
- package/dist/assets/.arcane/spells/spell-save-idea.md +5 -1
- package/dist/assets/.arcane/spells/spell-ship.md +13 -1
- package/dist/assets/.arcane/spells/spell-sync-pull-request.md +12 -0
- package/dist/index.js +1033 -432
- 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 dirname11, join as
|
|
9
|
+
import { dirname as dirname11, join as join19 } from "path";
|
|
10
10
|
|
|
11
11
|
// src/commands/init.ts
|
|
12
|
-
import { select as
|
|
13
|
-
import { dirname as dirname4, join as
|
|
12
|
+
import { select as select3, confirm as confirm2, input as input3 } from "@inquirer/prompts";
|
|
13
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
14
14
|
import chalk2 from "chalk";
|
|
15
15
|
|
|
16
16
|
// src/modules/copier.ts
|
|
@@ -140,11 +140,13 @@ var ManifestCorruptError = class extends Error {
|
|
|
140
140
|
}
|
|
141
141
|
};
|
|
142
142
|
var ManifestInvalidFieldError = class extends Error {
|
|
143
|
+
field;
|
|
143
144
|
constructor(manifestPath2, field, value) {
|
|
144
145
|
super(
|
|
145
146
|
`Manifest at "${manifestPath2}" has an unsupported value for "${field}": ${JSON.stringify(value)}.`
|
|
146
147
|
);
|
|
147
148
|
this.name = "ManifestInvalidFieldError";
|
|
149
|
+
this.field = field;
|
|
148
150
|
}
|
|
149
151
|
};
|
|
150
152
|
var MANIFEST_FILE = ".arcane.json";
|
|
@@ -152,6 +154,12 @@ var VALID_TRACKING_MODES = ["internal", "external"];
|
|
|
152
154
|
var VALID_EXTERNAL_PROVIDERS = ["ado", "github", "jira", "other"];
|
|
153
155
|
var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
|
|
154
156
|
var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
|
|
157
|
+
var MANIFEST_ENUM_VALUES = {
|
|
158
|
+
tracking_mode: VALID_TRACKING_MODES,
|
|
159
|
+
external_provider: VALID_EXTERNAL_PROVIDERS,
|
|
160
|
+
content_sensitivity: VALID_CONTENT_SENSITIVITY,
|
|
161
|
+
push_policy: VALID_PUSH_POLICIES
|
|
162
|
+
};
|
|
155
163
|
var VALID_INSTALL_SCOPES = ["repo", "user"];
|
|
156
164
|
function isValidFanoutPath(file) {
|
|
157
165
|
return file.length > 0 && !file.startsWith("/") && !/^[a-zA-Z]:/.test(file) && !file.includes("\\") && !file.split("/").includes("..") && !/[\r\n\0]/.test(file);
|
|
@@ -181,13 +189,16 @@ function validateSecretsScanExcludePrefixes(value, filePath) {
|
|
|
181
189
|
throw new ManifestInvalidFieldError(filePath, "secretsScanExcludePrefixes", value);
|
|
182
190
|
}
|
|
183
191
|
}
|
|
192
|
+
function isValidIdeaTags(value) {
|
|
193
|
+
return isRecord(value) && (value["mode"] === "extend" || value["mode"] === "replace") && Array.isArray(value["tags"]) && value["tags"].every((tag) => typeof tag === "string" && tag.trim() !== "");
|
|
194
|
+
}
|
|
184
195
|
function manifestPath(targetDir) {
|
|
185
196
|
return path2.join(targetDir, MANIFEST_FILE);
|
|
186
197
|
}
|
|
187
198
|
function isRecord(value) {
|
|
188
199
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
189
200
|
}
|
|
190
|
-
function
|
|
201
|
+
function validateManifestFields(manifest, filePath) {
|
|
191
202
|
if (manifest.tracking_mode !== void 0 && !VALID_TRACKING_MODES.includes(manifest.tracking_mode)) {
|
|
192
203
|
throw new ManifestInvalidFieldError(filePath, "tracking_mode", manifest.tracking_mode);
|
|
193
204
|
}
|
|
@@ -222,6 +233,9 @@ function validateTrackingFields(manifest, filePath) {
|
|
|
222
233
|
if (manifest.fanout !== void 0 && !isValidFanoutRecord(manifest.fanout)) {
|
|
223
234
|
throw new ManifestInvalidFieldError(filePath, "fanout", manifest.fanout);
|
|
224
235
|
}
|
|
236
|
+
if (manifest.idea_tags !== void 0 && !isValidIdeaTags(manifest.idea_tags)) {
|
|
237
|
+
throw new ManifestInvalidFieldError(filePath, "idea_tags", manifest.idea_tags);
|
|
238
|
+
}
|
|
225
239
|
}
|
|
226
240
|
async function readManifest(targetDir) {
|
|
227
241
|
const filePath = manifestPath(targetDir);
|
|
@@ -240,7 +254,7 @@ async function readManifest(targetDir) {
|
|
|
240
254
|
} catch {
|
|
241
255
|
throw new ManifestCorruptError(filePath);
|
|
242
256
|
}
|
|
243
|
-
|
|
257
|
+
validateManifestFields(manifest, filePath);
|
|
244
258
|
return manifest;
|
|
245
259
|
}
|
|
246
260
|
async function resolveSecretsScanExcludePrefixes(targetDir) {
|
|
@@ -262,7 +276,16 @@ async function resolveSecretsScanExcludePrefixes(targetDir) {
|
|
|
262
276
|
}
|
|
263
277
|
async function writeManifest(targetDir, manifest) {
|
|
264
278
|
const filePath = manifestPath(targetDir);
|
|
265
|
-
|
|
279
|
+
let existing = "";
|
|
280
|
+
try {
|
|
281
|
+
existing = await readFile2(filePath, "utf-8");
|
|
282
|
+
} catch {
|
|
283
|
+
existing = "";
|
|
284
|
+
}
|
|
285
|
+
const eol = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
286
|
+
const trailingNewline = /\r?\n$/.test(existing) ? eol : "";
|
|
287
|
+
const body = JSON.stringify(manifest, null, 2).replaceAll("\n", eol);
|
|
288
|
+
await writeFile2(filePath, body + trailingNewline, "utf-8");
|
|
266
289
|
}
|
|
267
290
|
function addComponent(manifest, component) {
|
|
268
291
|
return {
|
|
@@ -2637,10 +2660,10 @@ async function runAgentsList(targetDir, scope = "repo") {
|
|
|
2637
2660
|
console.log();
|
|
2638
2661
|
}
|
|
2639
2662
|
|
|
2640
|
-
// src/modules/
|
|
2641
|
-
import {
|
|
2642
|
-
import {
|
|
2643
|
-
import { join as join5
|
|
2663
|
+
// src/modules/hub.ts
|
|
2664
|
+
import { confirm, select as select2, input as input2 } from "@inquirer/prompts";
|
|
2665
|
+
import { readdir as readdir5, writeFile as writeFile7 } from "fs/promises";
|
|
2666
|
+
import { join as join5 } from "path";
|
|
2644
2667
|
|
|
2645
2668
|
// src/modules/git.ts
|
|
2646
2669
|
import { execFile } from "child_process";
|
|
@@ -2860,7 +2883,371 @@ async function ensureLocalPullRebase(cwd) {
|
|
|
2860
2883
|
}
|
|
2861
2884
|
}
|
|
2862
2885
|
|
|
2886
|
+
// src/modules/ado-context.ts
|
|
2887
|
+
function remoteUrlHost(url) {
|
|
2888
|
+
const trimmed = url.trim();
|
|
2889
|
+
if (trimmed.includes("://")) {
|
|
2890
|
+
try {
|
|
2891
|
+
return new URL(trimmed).hostname.toLowerCase() || void 0;
|
|
2892
|
+
} catch {
|
|
2893
|
+
return void 0;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
const scp = /^(?:[^@/\s]+@)?([^:/\s]+):/.exec(trimmed);
|
|
2897
|
+
if (!scp || /^[a-zA-Z]$/.test(scp[1])) return void 0;
|
|
2898
|
+
return scp[1].toLowerCase();
|
|
2899
|
+
}
|
|
2900
|
+
function isAdoRemoteUrl(url) {
|
|
2901
|
+
const host = remoteUrlHost(url);
|
|
2902
|
+
if (host === void 0) return false;
|
|
2903
|
+
return host === "dev.azure.com" || host.endsWith(".dev.azure.com") || host.endsWith(".visualstudio.com");
|
|
2904
|
+
}
|
|
2905
|
+
async function detectAdoContext(targetDir) {
|
|
2906
|
+
let stdout;
|
|
2907
|
+
try {
|
|
2908
|
+
({ stdout } = await runGit(targetDir, ["config", "--get-regexp", "^remote\\..*\\.url$"]));
|
|
2909
|
+
} catch {
|
|
2910
|
+
return false;
|
|
2911
|
+
}
|
|
2912
|
+
return stdout.split("\n").map((line) => line.slice(line.indexOf(" ") + 1)).some((url) => url.trim() !== "" && isAdoRemoteUrl(url));
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// src/modules/hub.ts
|
|
2916
|
+
async function askTrackingQuestions(targetDir) {
|
|
2917
|
+
const ado = targetDir !== void 0 && await detectAdoContext(targetDir);
|
|
2918
|
+
const tracking_mode = await select2({
|
|
2919
|
+
message: "How will work be tracked in this repo?",
|
|
2920
|
+
choices: [
|
|
2921
|
+
{
|
|
2922
|
+
value: "internal",
|
|
2923
|
+
name: "Track work in this repo (TODO.md / PRDs)",
|
|
2924
|
+
description: "Spells keep TODOs, PRDs and stories in this repository's own files."
|
|
2925
|
+
},
|
|
2926
|
+
{
|
|
2927
|
+
value: "external",
|
|
2928
|
+
name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)",
|
|
2929
|
+
description: "Spells tie PRDs, commits and PRs to items in the tracker you pick next."
|
|
2930
|
+
}
|
|
2931
|
+
],
|
|
2932
|
+
...ado ? { default: "external" } : {}
|
|
2933
|
+
});
|
|
2934
|
+
if (tracking_mode !== "external") return { tracking_mode, external_provider: null };
|
|
2935
|
+
const external_provider = await select2({
|
|
2936
|
+
message: "Which external tracker?",
|
|
2937
|
+
choices: [
|
|
2938
|
+
{
|
|
2939
|
+
value: "ado",
|
|
2940
|
+
name: "Azure DevOps",
|
|
2941
|
+
description: "Each PRD needs an ADO work item ID; plans follow your process template's item types."
|
|
2942
|
+
},
|
|
2943
|
+
{
|
|
2944
|
+
value: "github",
|
|
2945
|
+
name: "GitHub Issues",
|
|
2946
|
+
description: "Uses the gh CLI; an issue number is optional and linked from commits and PRs when known."
|
|
2947
|
+
},
|
|
2948
|
+
{
|
|
2949
|
+
value: "jira",
|
|
2950
|
+
name: "Jira",
|
|
2951
|
+
description: "No Jira automation yet: spells note linking as a TODO and keep planning in this repo."
|
|
2952
|
+
},
|
|
2953
|
+
{
|
|
2954
|
+
value: "other",
|
|
2955
|
+
name: "Other",
|
|
2956
|
+
description: "No automation: spells note linking as a TODO and keep planning in this repo."
|
|
2957
|
+
}
|
|
2958
|
+
],
|
|
2959
|
+
...ado ? { default: "ado" } : {}
|
|
2960
|
+
});
|
|
2961
|
+
return { tracking_mode, external_provider };
|
|
2962
|
+
}
|
|
2963
|
+
var MANIFEST_RETROFITS = [
|
|
2964
|
+
{
|
|
2965
|
+
field: "role",
|
|
2966
|
+
flag: "--role <hub|consumer>",
|
|
2967
|
+
needsRetrofit: (m) => m.role === void 0,
|
|
2968
|
+
ask: async () => {
|
|
2969
|
+
const isHub = await confirm({
|
|
2970
|
+
message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
|
|
2971
|
+
default: false
|
|
2972
|
+
});
|
|
2973
|
+
return { role: isHub ? "hub" : "consumer" };
|
|
2974
|
+
}
|
|
2975
|
+
},
|
|
2976
|
+
{
|
|
2977
|
+
field: "tracking_mode",
|
|
2978
|
+
flag: "--tracking-mode <internal|external>",
|
|
2979
|
+
needsRetrofit: (m) => m.tracking_mode === void 0,
|
|
2980
|
+
// Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
|
|
2981
|
+
// profiles get a silent default, full/lite get asked -- through the same
|
|
2982
|
+
// askTrackingQuestions init uses, so the two cannot drift.
|
|
2983
|
+
ask: async (manifest, targetDir) => {
|
|
2984
|
+
if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
|
|
2985
|
+
return { tracking_mode: "internal", external_provider: null };
|
|
2986
|
+
}
|
|
2987
|
+
return askTrackingQuestions(targetDir);
|
|
2988
|
+
}
|
|
2989
|
+
},
|
|
2990
|
+
{
|
|
2991
|
+
field: "content_sensitivity",
|
|
2992
|
+
flag: "--content-sensitivity <standard|sensitive>",
|
|
2993
|
+
needsRetrofit: (m) => m.content_sensitivity === void 0,
|
|
2994
|
+
// EF-12. Asked for every profile -- a code repo can hold sensitive records
|
|
2995
|
+
// too -- and defaults to "standard", so an operator who just presses enter
|
|
2996
|
+
// keeps today's behaviour exactly.
|
|
2997
|
+
ask: async () => {
|
|
2998
|
+
const content_sensitivity = await select2({
|
|
2999
|
+
message: "How should agents treat this repository's contents?",
|
|
3000
|
+
choices: [
|
|
3001
|
+
{
|
|
3002
|
+
value: "standard",
|
|
3003
|
+
name: "Standard \u2014 agents may quote contents in journals and decisions"
|
|
3004
|
+
},
|
|
3005
|
+
{
|
|
3006
|
+
value: "sensitive",
|
|
3007
|
+
name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
|
|
3008
|
+
}
|
|
3009
|
+
],
|
|
3010
|
+
default: "standard"
|
|
3011
|
+
});
|
|
3012
|
+
return { content_sensitivity };
|
|
3013
|
+
}
|
|
3014
|
+
},
|
|
3015
|
+
{
|
|
3016
|
+
field: "subject_root",
|
|
3017
|
+
flag: "--subject-root <path>",
|
|
3018
|
+
// EF-07. Only the docs profile is asked: other profiles describe code or a
|
|
3019
|
+
// venture portfolio, where "what is this repo about" is already answered.
|
|
3020
|
+
// A docs install that legitimately holds several subjects answers
|
|
3021
|
+
// "portfolio" and stays unset -- so this retrofit deliberately does NOT
|
|
3022
|
+
// re-ask on every update. It is gated on the field being absent AND the
|
|
3023
|
+
// profile being docs, and once answered (either way) it never fires again,
|
|
3024
|
+
// because "portfolio" writes an explicit empty marker.
|
|
3025
|
+
needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
|
|
3026
|
+
ask: async () => {
|
|
3027
|
+
const shape = await select2({
|
|
3028
|
+
message: "What does this repository hold?",
|
|
3029
|
+
choices: [
|
|
3030
|
+
{
|
|
3031
|
+
value: "root",
|
|
3032
|
+
name: "One subject, at the repository root (documents sit alongside Arcane's files)"
|
|
3033
|
+
},
|
|
3034
|
+
{ value: "subdir", name: "One subject, in its own directory" },
|
|
3035
|
+
{ value: "portfolio", name: "Several subjects or ventures" }
|
|
3036
|
+
]
|
|
3037
|
+
});
|
|
3038
|
+
if (shape === "root") return { subject_root: "." };
|
|
3039
|
+
if (shape === "subdir") {
|
|
3040
|
+
const answer = await input2({
|
|
3041
|
+
message: "Directory holding the subject's documents:",
|
|
3042
|
+
default: "docs",
|
|
3043
|
+
validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
|
|
3044
|
+
});
|
|
3045
|
+
return { subject_root: answer.trim() || "docs" };
|
|
3046
|
+
}
|
|
3047
|
+
return { subject_root: null };
|
|
3048
|
+
}
|
|
3049
|
+
},
|
|
3050
|
+
{
|
|
3051
|
+
field: "push_policy",
|
|
3052
|
+
flag: "--push-policy <open|guarded|blocked>",
|
|
3053
|
+
needsRetrofit: (m) => m.push_policy === void 0,
|
|
3054
|
+
// EF-09. Defaults to "open", so an operator who just presses enter keeps
|
|
3055
|
+
// today's behaviour exactly. NOTE: this only records the choice -- unlike
|
|
3056
|
+
// init, the retrofit does not install the hook or disable the push URL,
|
|
3057
|
+
// because an update should not silently start blocking pushes in a repo
|
|
3058
|
+
// someone is mid-workflow in (ARC-034 decision 7). ARC-049 decision 2:
|
|
3059
|
+
// update prints pushPolicyNotice() instead, naming `spell block-push`.
|
|
3060
|
+
ask: async () => {
|
|
3061
|
+
const push_policy = await select2({
|
|
3062
|
+
message: "Should this repository be allowed to push to a remote?",
|
|
3063
|
+
choices: [
|
|
3064
|
+
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
3065
|
+
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
3066
|
+
{ value: "blocked", name: "No \u2014 block pushes" }
|
|
3067
|
+
],
|
|
3068
|
+
default: "open"
|
|
3069
|
+
});
|
|
3070
|
+
return { push_policy };
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
];
|
|
3074
|
+
function pushPolicyNotice(policy) {
|
|
3075
|
+
if (policy === "blocked") {
|
|
3076
|
+
return [
|
|
3077
|
+
"",
|
|
3078
|
+
' ! Required action: push_policy is now recorded as "blocked", but it is NOT enforced yet.',
|
|
3079
|
+
" `spell update` never installs push controls. Pushes from this repository still work",
|
|
3080
|
+
" until you run:",
|
|
3081
|
+
"",
|
|
3082
|
+
" spell block-push",
|
|
3083
|
+
""
|
|
3084
|
+
];
|
|
3085
|
+
}
|
|
3086
|
+
if (policy === "guarded") {
|
|
3087
|
+
return [
|
|
3088
|
+
"",
|
|
3089
|
+
' push_policy is now recorded as "guarded". Nothing is installed for it: `spell doctor`',
|
|
3090
|
+
" reports it, and push-performing spells state the policy and ask before pushing.",
|
|
3091
|
+
""
|
|
3092
|
+
];
|
|
3093
|
+
}
|
|
3094
|
+
return [];
|
|
3095
|
+
}
|
|
3096
|
+
async function runManifestRetrofits(manifest, targetDir) {
|
|
3097
|
+
const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
|
|
3098
|
+
if (applicable.length === 0) return {};
|
|
3099
|
+
console.log();
|
|
3100
|
+
console.log(
|
|
3101
|
+
`This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
|
|
3102
|
+
);
|
|
3103
|
+
let patch = {};
|
|
3104
|
+
for (const retrofit of applicable) {
|
|
3105
|
+
const answer = await retrofit.ask({ ...manifest, ...patch }, targetDir);
|
|
3106
|
+
patch = { ...patch, ...answer };
|
|
3107
|
+
}
|
|
3108
|
+
return patch;
|
|
3109
|
+
}
|
|
3110
|
+
function manifestFlagLine(manifest) {
|
|
3111
|
+
const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
|
|
3112
|
+
if (pending.length === 0) return void 0;
|
|
3113
|
+
const lines = [`spell update ${pending.map((r) => r.flag).join(" ")}`];
|
|
3114
|
+
if (pending.some((r) => r.field === "tracking_mode")) {
|
|
3115
|
+
lines.push("(--tracking-mode external also needs --external-provider <ado|github|jira|other>)");
|
|
3116
|
+
}
|
|
3117
|
+
return lines;
|
|
3118
|
+
}
|
|
3119
|
+
var ManifestFlagError = class extends Error {
|
|
3120
|
+
constructor(message) {
|
|
3121
|
+
super(message);
|
|
3122
|
+
this.name = "ManifestFlagError";
|
|
3123
|
+
}
|
|
3124
|
+
};
|
|
3125
|
+
var FLAG_NAMES = {
|
|
3126
|
+
role: "--role",
|
|
3127
|
+
tracking_mode: "--tracking-mode",
|
|
3128
|
+
external_provider: "--external-provider",
|
|
3129
|
+
content_sensitivity: "--content-sensitivity",
|
|
3130
|
+
subject_root: "--subject-root",
|
|
3131
|
+
push_policy: "--push-policy"
|
|
3132
|
+
};
|
|
3133
|
+
var PUSH_POLICY_STRICTNESS = { open: 0, guarded: 1, blocked: 2 };
|
|
3134
|
+
function hasManifestFlags(flags) {
|
|
3135
|
+
return flags !== void 0 && Object.values(flags).some((v) => v !== void 0);
|
|
3136
|
+
}
|
|
3137
|
+
function resolveManifestFlags(flags, current, manifestPath2) {
|
|
3138
|
+
if (!hasManifestFlags(flags)) return {};
|
|
3139
|
+
const requested = {};
|
|
3140
|
+
if (flags.role !== void 0) requested.role = flags.role;
|
|
3141
|
+
if (flags.trackingMode !== void 0) requested.tracking_mode = flags.trackingMode;
|
|
3142
|
+
if (flags.externalProvider !== void 0) requested.external_provider = flags.externalProvider;
|
|
3143
|
+
if (flags.contentSensitivity !== void 0) requested.content_sensitivity = flags.contentSensitivity;
|
|
3144
|
+
if (flags.subjectRoot !== void 0) requested.subject_root = flags.subjectRoot;
|
|
3145
|
+
if (flags.pushPolicy !== void 0) requested.push_policy = flags.pushPolicy;
|
|
3146
|
+
const invalid = (error) => {
|
|
3147
|
+
const allowed = error.field === "role" ? ["hub", "consumer"] : MANIFEST_ENUM_VALUES[error.field];
|
|
3148
|
+
const hint = allowed ? ` Valid values: ${allowed.join(", ")}.` : "";
|
|
3149
|
+
return new ManifestFlagError(`Invalid ${FLAG_NAMES[error.field] ?? error.field}: ${error.message}${hint}`);
|
|
3150
|
+
};
|
|
3151
|
+
if (requested.role !== void 0 && requested.role !== "hub" && requested.role !== "consumer") {
|
|
3152
|
+
throw invalid(new ManifestInvalidFieldError(manifestPath2, "role", requested.role));
|
|
3153
|
+
}
|
|
3154
|
+
try {
|
|
3155
|
+
validateManifestFields(requested, manifestPath2);
|
|
3156
|
+
} catch (error) {
|
|
3157
|
+
if (error instanceof ManifestInvalidFieldError) throw invalid(error);
|
|
3158
|
+
throw error;
|
|
3159
|
+
}
|
|
3160
|
+
if (requested.external_provider !== void 0 && requested.tracking_mode !== "external") {
|
|
3161
|
+
throw new ManifestFlagError("--external-provider applies only together with --tracking-mode external.");
|
|
3162
|
+
}
|
|
3163
|
+
if (requested.tracking_mode === "external" && requested.external_provider === void 0) {
|
|
3164
|
+
throw new ManifestFlagError(
|
|
3165
|
+
"--tracking-mode external needs --external-provider <ado|github|jira|other> as well."
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3168
|
+
if (requested.tracking_mode === "internal") requested.external_provider = null;
|
|
3169
|
+
const patch = {};
|
|
3170
|
+
for (const field of Object.keys(FLAG_NAMES)) {
|
|
3171
|
+
if (!(field in requested)) continue;
|
|
3172
|
+
const value = requested[field];
|
|
3173
|
+
const recorded = current[field];
|
|
3174
|
+
if (recorded === void 0) {
|
|
3175
|
+
patch[field] = value;
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
if (field === "push_policy") {
|
|
3179
|
+
const from = PUSH_POLICY_STRICTNESS[recorded];
|
|
3180
|
+
const to = PUSH_POLICY_STRICTNESS[value];
|
|
3181
|
+
if (to < from) {
|
|
3182
|
+
throw new ManifestFlagError(
|
|
3183
|
+
`--push-policy ${String(value)} would loosen this repository's recorded push_policy "${String(recorded)}". No flag can loosen it: run \`spell unblock-push\` from an interactive terminal.`
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
if (to > from) patch[field] = value;
|
|
3187
|
+
continue;
|
|
3188
|
+
}
|
|
3189
|
+
if (recorded === value) continue;
|
|
3190
|
+
const flag = field === "external_provider" && flags.externalProvider === void 0 ? "--tracking-mode" : FLAG_NAMES[field];
|
|
3191
|
+
throw new ManifestFlagError(
|
|
3192
|
+
`${flag} conflicts with the recorded ${field}: ${JSON.stringify(recorded)}. A flag only answers a question that has no recorded answer; change .arcane.json deliberately if the recorded value is wrong.`
|
|
3193
|
+
);
|
|
3194
|
+
}
|
|
3195
|
+
return patch;
|
|
3196
|
+
}
|
|
3197
|
+
function describeFlagPatch(patch) {
|
|
3198
|
+
return Object.entries(patch).filter(([field]) => field in FLAG_NAMES).map(([field, value]) => `${field}: ${JSON.stringify(value)}`);
|
|
3199
|
+
}
|
|
3200
|
+
async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
3201
|
+
const registryPath = join5(targetDir, businessRoot, "registry.json");
|
|
3202
|
+
if (await fileExists(registryPath)) return;
|
|
3203
|
+
const businessRootPath = join5(targetDir, businessRoot);
|
|
3204
|
+
let ventureDirs;
|
|
3205
|
+
try {
|
|
3206
|
+
const entries = await readdir5(businessRootPath, { withFileTypes: true });
|
|
3207
|
+
ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
|
|
3208
|
+
} catch {
|
|
3209
|
+
return;
|
|
3210
|
+
}
|
|
3211
|
+
if (ventureDirs.length === 0) return;
|
|
3212
|
+
console.log();
|
|
3213
|
+
const scaffold = await confirm({
|
|
3214
|
+
message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
|
|
3215
|
+
default: true
|
|
3216
|
+
});
|
|
3217
|
+
if (!scaffold) return;
|
|
3218
|
+
const ventures = {};
|
|
3219
|
+
for (const slug of ventureDirs) {
|
|
3220
|
+
const include = await confirm({ message: ` Include "${slug}"?`, default: true });
|
|
3221
|
+
if (!include) continue;
|
|
3222
|
+
ventures[slug] = {
|
|
3223
|
+
name: slug,
|
|
3224
|
+
aliases: [],
|
|
3225
|
+
status: "active",
|
|
3226
|
+
visibility: "private",
|
|
3227
|
+
ownership: "llc",
|
|
3228
|
+
tracking: "none",
|
|
3229
|
+
repos: []
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
if (Object.keys(ventures).length === 0) {
|
|
3233
|
+
console.log(" No ventures selected -- registry not created.");
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
const registry = {
|
|
3237
|
+
_comment: "Hub-owned venture registry. Private. Never installed or modified by spell update. Seeded by the retrofit wizard -- review and enrich (aliases, ownership, tracking, clones) before relying on it.",
|
|
3238
|
+
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
3239
|
+
ventures
|
|
3240
|
+
};
|
|
3241
|
+
await writeFile7(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
|
|
3242
|
+
console.log(
|
|
3243
|
+
` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
|
|
3244
|
+
);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
2863
3247
|
// src/modules/push-safety.ts
|
|
3248
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile8, chmod, access as access3, rm as rm4, readdir as readdir6 } from "fs/promises";
|
|
3249
|
+
import { constants } from "fs";
|
|
3250
|
+
import { join as join6, resolve as resolve2, dirname as dirname3, basename } from "path";
|
|
2864
3251
|
var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
|
|
2865
3252
|
var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
|
|
2866
3253
|
var ARCANE_HOOKS_DIR = ".arcane/hooks";
|
|
@@ -2951,10 +3338,10 @@ async function repositoryRoot(cwd) {
|
|
|
2951
3338
|
return cwd;
|
|
2952
3339
|
}
|
|
2953
3340
|
async function hookFilePath(cwd, hookName = "pre-push") {
|
|
2954
|
-
return
|
|
3341
|
+
return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
|
|
2955
3342
|
}
|
|
2956
3343
|
async function hooksPathValue(cwd) {
|
|
2957
|
-
return
|
|
3344
|
+
return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
|
|
2958
3345
|
}
|
|
2959
3346
|
async function installHook(cwd, hookName, body) {
|
|
2960
3347
|
const root = await repositoryRoot(cwd);
|
|
@@ -2973,12 +3360,12 @@ async function installHook(cwd, hookName, body) {
|
|
|
2973
3360
|
return { status: "refused-default-hooks", hooks: displaced };
|
|
2974
3361
|
}
|
|
2975
3362
|
}
|
|
2976
|
-
const hookPath =
|
|
3363
|
+
const hookPath = join6(root, ARCANE_HOOKS_DIR, hookName);
|
|
2977
3364
|
if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
|
|
2978
3365
|
return { status: "already-ours" };
|
|
2979
3366
|
}
|
|
2980
|
-
await mkdir5(
|
|
2981
|
-
await
|
|
3367
|
+
await mkdir5(join6(root, ARCANE_HOOKS_DIR), { recursive: true });
|
|
3368
|
+
await writeFile8(hookPath, body, "utf-8");
|
|
2982
3369
|
await chmod(hookPath, 493);
|
|
2983
3370
|
await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
|
|
2984
3371
|
return { status: "installed", path: hookPath };
|
|
@@ -3022,12 +3409,12 @@ async function defaultHooks(cwd) {
|
|
|
3022
3409
|
let hooksDir;
|
|
3023
3410
|
try {
|
|
3024
3411
|
const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
|
|
3025
|
-
hooksDir =
|
|
3412
|
+
hooksDir = join6(resolve2(cwd, stdout.trim()), "hooks");
|
|
3026
3413
|
} catch {
|
|
3027
3414
|
return [];
|
|
3028
3415
|
}
|
|
3029
3416
|
try {
|
|
3030
|
-
return (await
|
|
3417
|
+
return (await readdir6(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
|
|
3031
3418
|
} catch {
|
|
3032
3419
|
return [];
|
|
3033
3420
|
}
|
|
@@ -3058,10 +3445,10 @@ async function removeHook(cwd, hookName) {
|
|
|
3058
3445
|
await rm4(await hookFilePath(cwd, hookName), { force: true });
|
|
3059
3446
|
} catch {
|
|
3060
3447
|
}
|
|
3061
|
-
const hooksDir =
|
|
3448
|
+
const hooksDir = join6(root, ARCANE_HOOKS_DIR);
|
|
3062
3449
|
let remaining = [];
|
|
3063
3450
|
try {
|
|
3064
|
-
remaining = await
|
|
3451
|
+
remaining = await readdir6(hooksDir);
|
|
3065
3452
|
} catch {
|
|
3066
3453
|
remaining = [];
|
|
3067
3454
|
}
|
|
@@ -3281,6 +3668,60 @@ function describeError(error) {
|
|
|
3281
3668
|
const message = error instanceof Error ? error.message : String(error);
|
|
3282
3669
|
return message.split("\n")[0].trim();
|
|
3283
3670
|
}
|
|
3671
|
+
function hookInForce(outcome) {
|
|
3672
|
+
return outcome.status === "installed" || outcome.status === "already-ours";
|
|
3673
|
+
}
|
|
3674
|
+
async function applyBlockedPushControls(cwd, options = {}) {
|
|
3675
|
+
const messages = [];
|
|
3676
|
+
const say = (level, text) => {
|
|
3677
|
+
const message = { level, text };
|
|
3678
|
+
messages.push(message);
|
|
3679
|
+
options.emit?.(message);
|
|
3680
|
+
};
|
|
3681
|
+
const hook = await installPrePushHook(cwd);
|
|
3682
|
+
if (hook.status === "refused-unreadable-config") {
|
|
3683
|
+
say(
|
|
3684
|
+
"warning",
|
|
3685
|
+
"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`."
|
|
3686
|
+
);
|
|
3687
|
+
} else if (hook.status === "refused-default-hooks") {
|
|
3688
|
+
say(
|
|
3689
|
+
"warning",
|
|
3690
|
+
`Did not install the pre-push hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run, or add the push guard to your existing pre-push hook.`
|
|
3691
|
+
);
|
|
3692
|
+
} else if (hook.status === "refused-foreign-hooks-path") {
|
|
3693
|
+
say(
|
|
3694
|
+
"warning",
|
|
3695
|
+
`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.`
|
|
3696
|
+
);
|
|
3697
|
+
} else {
|
|
3698
|
+
say("info", "Installed a pre-push hook that blocks pushes from this repository.");
|
|
3699
|
+
}
|
|
3700
|
+
if (options.stopOnHookRefusal === true && !hookInForce(hook)) {
|
|
3701
|
+
return { hook, urls: [], stoppedAtHook: true, messages };
|
|
3702
|
+
}
|
|
3703
|
+
const urls = await disablePushUrls(cwd);
|
|
3704
|
+
const unprotected = urls.filter((u) => u.status === "failed");
|
|
3705
|
+
if (unprotected.length > 0) {
|
|
3706
|
+
say(
|
|
3707
|
+
"warning",
|
|
3708
|
+
`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.`
|
|
3709
|
+
);
|
|
3710
|
+
}
|
|
3711
|
+
if (urls.length === 0) {
|
|
3712
|
+
say(
|
|
3713
|
+
"warning",
|
|
3714
|
+
"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."
|
|
3715
|
+
);
|
|
3716
|
+
} else {
|
|
3717
|
+
const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
|
|
3718
|
+
if (covered.length > 0) {
|
|
3719
|
+
say("info", `Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
say("info", "Run `spell unblock-push` from a terminal to undo this.");
|
|
3723
|
+
return { hook, urls, stoppedAtHook: false, messages };
|
|
3724
|
+
}
|
|
3284
3725
|
|
|
3285
3726
|
// src/modules/secrets-scan.ts
|
|
3286
3727
|
var SECRETS_RULES = [
|
|
@@ -3390,6 +3831,13 @@ function buildProfileDescription(components, profile) {
|
|
|
3390
3831
|
}
|
|
3391
3832
|
async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
3392
3833
|
if (options.user) {
|
|
3834
|
+
if (hasManifestFlags(options.manifestFlags)) {
|
|
3835
|
+
console.error(
|
|
3836
|
+
"\n \u2717 The manifest-question flags describe a repository; the user tier has none of those fields.\n"
|
|
3837
|
+
);
|
|
3838
|
+
process.exit(1);
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3393
3841
|
await runInitUser(options, targetDir, assetsDir, packageVersion);
|
|
3394
3842
|
return;
|
|
3395
3843
|
}
|
|
@@ -3398,13 +3846,27 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3398
3846
|
`Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
|
|
3399
3847
|
);
|
|
3400
3848
|
}
|
|
3849
|
+
let flags = {};
|
|
3401
3850
|
try {
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
`
|
|
3406
|
-
|
|
3407
|
-
|
|
3851
|
+
flags = resolveManifestFlags(options.manifestFlags, {}, join7(targetDir, ".arcane.json"));
|
|
3852
|
+
} catch (err) {
|
|
3853
|
+
if (!(err instanceof ManifestFlagError)) throw err;
|
|
3854
|
+
console.error(`
|
|
3855
|
+
\u2717 ${err.message}
|
|
3856
|
+
`);
|
|
3857
|
+
process.exit(1);
|
|
3858
|
+
return;
|
|
3859
|
+
}
|
|
3860
|
+
try {
|
|
3861
|
+
await readManifest(targetDir);
|
|
3862
|
+
console.log(chalk2.hex("#a855f7")(`
|
|
3863
|
+
\u2726 Arcane v${packageVersion}
|
|
3864
|
+
`));
|
|
3865
|
+
console.log('Already initialized. Run "spell update" to update existing files.');
|
|
3866
|
+
if (hasManifestFlags(options.manifestFlags)) {
|
|
3867
|
+
console.log("Pass the same manifest-question flags to `spell update` to record them here.");
|
|
3868
|
+
}
|
|
3869
|
+
return;
|
|
3408
3870
|
} catch (err) {
|
|
3409
3871
|
if (!(err instanceof ManifestNotFoundError)) throw err;
|
|
3410
3872
|
}
|
|
@@ -3435,7 +3897,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3435
3897
|
);
|
|
3436
3898
|
printInfo("Running spell init will add new files to your working tree.");
|
|
3437
3899
|
console.log();
|
|
3438
|
-
const continueAnyway = await
|
|
3900
|
+
const continueAnyway = await confirm2({
|
|
3439
3901
|
message: "Continue anyway?",
|
|
3440
3902
|
default: true
|
|
3441
3903
|
});
|
|
@@ -3456,7 +3918,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3456
3918
|
let profile = options.profile;
|
|
3457
3919
|
if (!profile) {
|
|
3458
3920
|
const profiles = listProfiles();
|
|
3459
|
-
profile = await
|
|
3921
|
+
profile = await select3({
|
|
3460
3922
|
message: "Select an installation profile:\n",
|
|
3461
3923
|
choices: profiles.map((p) => {
|
|
3462
3924
|
const profileComponents2 = getProfile(p.id);
|
|
@@ -3481,7 +3943,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3481
3943
|
}
|
|
3482
3944
|
if (storeVersion !== void 0) {
|
|
3483
3945
|
console.log();
|
|
3484
|
-
const useTier = await
|
|
3946
|
+
const useTier = await confirm2({
|
|
3485
3947
|
message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
|
|
3486
3948
|
default: preferred === "user"
|
|
3487
3949
|
});
|
|
@@ -3504,7 +3966,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3504
3966
|
console.log(` ${previewParts.join(" \xB7 ")}`);
|
|
3505
3967
|
console.log();
|
|
3506
3968
|
if (!options.dryRun && !options.profile) {
|
|
3507
|
-
const proceed = await
|
|
3969
|
+
const proceed = await confirm2({
|
|
3508
3970
|
message: "Proceed with installation?",
|
|
3509
3971
|
default: true
|
|
3510
3972
|
});
|
|
@@ -3519,7 +3981,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3519
3981
|
const installedFiles = [];
|
|
3520
3982
|
const fileHashes = {};
|
|
3521
3983
|
for (const file of component.files) {
|
|
3522
|
-
const srcPath =
|
|
3984
|
+
const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
3523
3985
|
if (options.dryRun) {
|
|
3524
3986
|
printDryRun(`Would copy: ${file}`);
|
|
3525
3987
|
} else {
|
|
@@ -3536,7 +3998,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3536
3998
|
fileCount++;
|
|
3537
3999
|
}
|
|
3538
4000
|
for (const dir of component.directories ?? []) {
|
|
3539
|
-
const srcDirPath =
|
|
4001
|
+
const srcDirPath = join7(assetsDir, dir);
|
|
3540
4002
|
if (options.dryRun) {
|
|
3541
4003
|
printDryRun(`Would copy directory: ${dir}/`);
|
|
3542
4004
|
} else {
|
|
@@ -3565,7 +4027,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3565
4027
|
if (options.dryRun) {
|
|
3566
4028
|
if (hasAgents) {
|
|
3567
4029
|
const agentFiles = await import("fs").then(
|
|
3568
|
-
(fsModule) => fsModule.promises.readdir(
|
|
4030
|
+
(fsModule) => fsModule.promises.readdir(join7(assetsDir, "agents"))
|
|
3569
4031
|
);
|
|
3570
4032
|
const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
|
|
3571
4033
|
printDryRun("Would run agent setup (interactive):");
|
|
@@ -3577,10 +4039,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3577
4039
|
printDryRun(`Would initialize with profile "${profile}" \u2014 ${fileCount} files`);
|
|
3578
4040
|
return;
|
|
3579
4041
|
}
|
|
3580
|
-
let role;
|
|
3581
|
-
if (!options.profile) {
|
|
4042
|
+
let role = flags.role;
|
|
4043
|
+
if (role === void 0 && !options.profile) {
|
|
3582
4044
|
console.log();
|
|
3583
|
-
const isHub = await
|
|
4045
|
+
const isHub = await confirm2({
|
|
3584
4046
|
message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
|
|
3585
4047
|
default: false
|
|
3586
4048
|
});
|
|
@@ -3588,36 +4050,20 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3588
4050
|
}
|
|
3589
4051
|
let tracking_mode;
|
|
3590
4052
|
let external_provider;
|
|
3591
|
-
if (
|
|
4053
|
+
if (flags.tracking_mode !== void 0) {
|
|
4054
|
+
tracking_mode = flags.tracking_mode;
|
|
4055
|
+
external_provider = flags.external_provider ?? null;
|
|
4056
|
+
} else if (profile === "governance-only" || profile === "methodology" || profile === "docs") {
|
|
3592
4057
|
tracking_mode = "internal";
|
|
3593
4058
|
external_provider = null;
|
|
3594
4059
|
} else if (!options.profile) {
|
|
3595
4060
|
console.log();
|
|
3596
|
-
tracking_mode = await
|
|
3597
|
-
message: "How will work be tracked in this repo?",
|
|
3598
|
-
choices: [
|
|
3599
|
-
{ value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
|
|
3600
|
-
{ value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
|
|
3601
|
-
]
|
|
3602
|
-
});
|
|
3603
|
-
if (tracking_mode === "external") {
|
|
3604
|
-
external_provider = await select2({
|
|
3605
|
-
message: "Which external tracker?",
|
|
3606
|
-
choices: [
|
|
3607
|
-
{ value: "ado", name: "Azure DevOps" },
|
|
3608
|
-
{ value: "github", name: "GitHub Issues" },
|
|
3609
|
-
{ value: "jira", name: "Jira" },
|
|
3610
|
-
{ value: "other", name: "Other" }
|
|
3611
|
-
]
|
|
3612
|
-
});
|
|
3613
|
-
} else {
|
|
3614
|
-
external_provider = null;
|
|
3615
|
-
}
|
|
4061
|
+
({ tracking_mode, external_provider } = await askTrackingQuestions(targetDir));
|
|
3616
4062
|
}
|
|
3617
|
-
let subject_root;
|
|
3618
|
-
if (profile === "docs" && !options.profile) {
|
|
4063
|
+
let subject_root = flags.subject_root;
|
|
4064
|
+
if (subject_root === void 0 && profile === "docs" && !options.profile) {
|
|
3619
4065
|
console.log();
|
|
3620
|
-
const shape = await
|
|
4066
|
+
const shape = await select3({
|
|
3621
4067
|
message: "What does this repository hold?",
|
|
3622
4068
|
choices: [
|
|
3623
4069
|
{
|
|
@@ -3631,7 +4077,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3631
4077
|
if (shape === "root") {
|
|
3632
4078
|
subject_root = ".";
|
|
3633
4079
|
} else if (shape === "subdir") {
|
|
3634
|
-
const answer = await
|
|
4080
|
+
const answer = await input3({
|
|
3635
4081
|
message: "Directory holding the subject's documents:",
|
|
3636
4082
|
default: "docs",
|
|
3637
4083
|
validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
|
|
@@ -3641,10 +4087,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3641
4087
|
subject_root = null;
|
|
3642
4088
|
}
|
|
3643
4089
|
}
|
|
3644
|
-
let content_sensitivity;
|
|
3645
|
-
if (!options.profile) {
|
|
4090
|
+
let content_sensitivity = flags.content_sensitivity;
|
|
4091
|
+
if (content_sensitivity === void 0 && !options.profile) {
|
|
3646
4092
|
console.log();
|
|
3647
|
-
content_sensitivity = await
|
|
4093
|
+
content_sensitivity = await select3({
|
|
3648
4094
|
message: "How should agents treat this repository's contents?",
|
|
3649
4095
|
choices: [
|
|
3650
4096
|
{ value: "standard", name: "Standard \u2014 agents may quote contents in journals and decisions" },
|
|
@@ -3656,10 +4102,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3656
4102
|
default: "standard"
|
|
3657
4103
|
});
|
|
3658
4104
|
}
|
|
3659
|
-
let push_policy;
|
|
3660
|
-
if (!options.profile) {
|
|
4105
|
+
let push_policy = flags.push_policy;
|
|
4106
|
+
if (push_policy === void 0 && !options.profile) {
|
|
3661
4107
|
console.log();
|
|
3662
|
-
push_policy = await
|
|
4108
|
+
push_policy = await select3({
|
|
3663
4109
|
message: "Should this repository be allowed to push to a remote?",
|
|
3664
4110
|
choices: [
|
|
3665
4111
|
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
@@ -3686,43 +4132,23 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3686
4132
|
};
|
|
3687
4133
|
await writeManifest(targetDir, manifest);
|
|
3688
4134
|
printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
|
|
4135
|
+
const flagLine = options.profile ? manifestFlagLine(manifest) : void 0;
|
|
4136
|
+
if (flagLine) {
|
|
4137
|
+
printInfo("Some setup questions were left unanswered. Answer them without a prompt with:");
|
|
4138
|
+
for (const line of flagLine) printInfo(` ${line}`);
|
|
4139
|
+
}
|
|
3689
4140
|
if (push_policy === "blocked" || push_policy === "guarded") {
|
|
3690
4141
|
try {
|
|
3691
4142
|
if (push_policy === "blocked") {
|
|
3692
|
-
const
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
);
|
|
3697
|
-
} else if (hook.status === "refused-default-hooks") {
|
|
3698
|
-
printWarning(
|
|
3699
|
-
`Did not install the pre-push hook: this repository has hooks in git's default directory (${hook.hooks.join(", ")}). Setting core.hooksPath would silently stop them running. Move them under \`${ARCANE_HOOKS_DIR}\` yourself and re-run, or add the push guard to your existing pre-push hook.`
|
|
3700
|
-
);
|
|
3701
|
-
} else if (hook.status === "refused-foreign-hooks-path") {
|
|
3702
|
-
printWarning(
|
|
3703
|
-
`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.`
|
|
3704
|
-
);
|
|
3705
|
-
} else {
|
|
3706
|
-
printInfo("Installed a pre-push hook that blocks pushes from this repository.");
|
|
3707
|
-
}
|
|
3708
|
-
const urls = await disablePushUrls(targetDir);
|
|
3709
|
-
const unprotected = urls.filter((u) => u.status === "failed");
|
|
3710
|
-
if (unprotected.length > 0) {
|
|
3711
|
-
printWarning(
|
|
3712
|
-
`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.`
|
|
3713
|
-
);
|
|
3714
|
-
}
|
|
3715
|
-
if (urls.length === 0) {
|
|
4143
|
+
const controls = await applyBlockedPushControls(targetDir, {
|
|
4144
|
+
emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
|
|
4145
|
+
});
|
|
4146
|
+
if (!hookInForce(controls.hook) || controls.urls.some((u) => u.status === "failed")) {
|
|
3716
4147
|
printWarning(
|
|
3717
|
-
|
|
4148
|
+
'push_policy is recorded as "blocked", but the block is not fully in force. Resolve the cause above, then run `spell block-push`; `spell doctor` shows exactly what is missing.'
|
|
3718
4149
|
);
|
|
3719
|
-
|
|
3720
|
-
const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
|
|
3721
|
-
if (covered.length > 0) {
|
|
3722
|
-
printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
|
|
3723
|
-
}
|
|
4150
|
+
process.exitCode = 1;
|
|
3724
4151
|
}
|
|
3725
|
-
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
3726
4152
|
} else {
|
|
3727
4153
|
printInfo(
|
|
3728
4154
|
"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."
|
|
@@ -3768,7 +4194,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3768
4194
|
let agentsConfigured = false;
|
|
3769
4195
|
if (hasAgents && !options.profile) {
|
|
3770
4196
|
console.log();
|
|
3771
|
-
const setupAgents = await
|
|
4197
|
+
const setupAgents = await confirm2({
|
|
3772
4198
|
message: "\u{1F916} Set up your agent team? (names, roles, AI tool configs)",
|
|
3773
4199
|
default: true
|
|
3774
4200
|
});
|
|
@@ -3814,6 +4240,11 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3814
4240
|
'"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
|
|
3815
4241
|
);
|
|
3816
4242
|
}
|
|
4243
|
+
if (hasManifestFlags(options.manifestFlags)) {
|
|
4244
|
+
throw new Error(
|
|
4245
|
+
'The manifest-question flags do not apply to "--user": they describe a repository, and the user tier is not one.'
|
|
4246
|
+
);
|
|
4247
|
+
}
|
|
3817
4248
|
try {
|
|
3818
4249
|
await readManifest(storeRoot);
|
|
3819
4250
|
console.log(chalk2.hex("#a855f7")(`
|
|
@@ -3847,7 +4278,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3847
4278
|
for (const id of spellIds) {
|
|
3848
4279
|
for (const client of ["codex", "claude"]) {
|
|
3849
4280
|
const relativePath = USER_FANOUT_PATHS[client](id);
|
|
3850
|
-
if (await fileExists(
|
|
4281
|
+
if (await fileExists(join7(homeDir, relativePath))) {
|
|
3851
4282
|
printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
|
|
3852
4283
|
} else {
|
|
3853
4284
|
printDryRun(`Would write: ~/${relativePath}`);
|
|
@@ -3865,7 +4296,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3865
4296
|
const installedFiles = [];
|
|
3866
4297
|
const fileHashes = {};
|
|
3867
4298
|
for (const file of component.files) {
|
|
3868
|
-
const srcPath =
|
|
4299
|
+
const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
3869
4300
|
fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
|
|
3870
4301
|
installedFiles.push(file);
|
|
3871
4302
|
fileCount++;
|
|
@@ -3911,7 +4342,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3911
4342
|
}
|
|
3912
4343
|
|
|
3913
4344
|
// src/commands/add.ts
|
|
3914
|
-
import { join as
|
|
4345
|
+
import { join as join8 } from "path";
|
|
3915
4346
|
async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
3916
4347
|
let manifest;
|
|
3917
4348
|
try {
|
|
@@ -3947,7 +4378,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
3947
4378
|
const installedFiles = [];
|
|
3948
4379
|
const fileHashes = {};
|
|
3949
4380
|
for (const file of component.files) {
|
|
3950
|
-
const srcPath =
|
|
4381
|
+
const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
3951
4382
|
if (options.dryRun) {
|
|
3952
4383
|
console.log(` [dry-run] Would copy: ${file}`);
|
|
3953
4384
|
} else {
|
|
@@ -3957,7 +4388,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
3957
4388
|
fileCount++;
|
|
3958
4389
|
}
|
|
3959
4390
|
for (const dir of component.directories ?? []) {
|
|
3960
|
-
const srcDirPath =
|
|
4391
|
+
const srcDirPath = join8(assetsDir, dir);
|
|
3961
4392
|
if (options.dryRun) {
|
|
3962
4393
|
console.log(` [dry-run] Would copy directory: ${dir}/`);
|
|
3963
4394
|
} else {
|
|
@@ -3994,194 +4425,6 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
3994
4425
|
import { dirname as dirname5, join as join9 } from "path";
|
|
3995
4426
|
import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
|
|
3996
4427
|
|
|
3997
|
-
// src/modules/hub.ts
|
|
3998
|
-
import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
|
|
3999
|
-
import { readdir as readdir6, writeFile as writeFile8 } from "fs/promises";
|
|
4000
|
-
import { join as join8 } from "path";
|
|
4001
|
-
var MANIFEST_RETROFITS = [
|
|
4002
|
-
{
|
|
4003
|
-
field: "role",
|
|
4004
|
-
needsRetrofit: (m) => m.role === void 0,
|
|
4005
|
-
ask: async () => {
|
|
4006
|
-
const isHub = await confirm2({
|
|
4007
|
-
message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
|
|
4008
|
-
default: false
|
|
4009
|
-
});
|
|
4010
|
-
return { role: isHub ? "hub" : "consumer" };
|
|
4011
|
-
}
|
|
4012
|
-
},
|
|
4013
|
-
{
|
|
4014
|
-
field: "tracking_mode",
|
|
4015
|
-
needsRetrofit: (m) => m.tracking_mode === void 0,
|
|
4016
|
-
// Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
|
|
4017
|
-
// profiles get a silent default, full/lite get asked.
|
|
4018
|
-
ask: async (manifest) => {
|
|
4019
|
-
if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
|
|
4020
|
-
return { tracking_mode: "internal", external_provider: null };
|
|
4021
|
-
}
|
|
4022
|
-
const tracking_mode = await select3({
|
|
4023
|
-
message: "How will work be tracked in this repo?",
|
|
4024
|
-
choices: [
|
|
4025
|
-
{ value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
|
|
4026
|
-
{ value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
|
|
4027
|
-
]
|
|
4028
|
-
});
|
|
4029
|
-
if (tracking_mode === "external") {
|
|
4030
|
-
const external_provider = await select3({
|
|
4031
|
-
message: "Which external tracker?",
|
|
4032
|
-
choices: [
|
|
4033
|
-
{ value: "ado", name: "Azure DevOps" },
|
|
4034
|
-
{ value: "github", name: "GitHub Issues" },
|
|
4035
|
-
{ value: "jira", name: "Jira" },
|
|
4036
|
-
{ value: "other", name: "Other" }
|
|
4037
|
-
]
|
|
4038
|
-
});
|
|
4039
|
-
return { tracking_mode, external_provider };
|
|
4040
|
-
}
|
|
4041
|
-
return { tracking_mode, external_provider: null };
|
|
4042
|
-
}
|
|
4043
|
-
},
|
|
4044
|
-
{
|
|
4045
|
-
field: "content_sensitivity",
|
|
4046
|
-
needsRetrofit: (m) => m.content_sensitivity === void 0,
|
|
4047
|
-
// EF-12. Asked for every profile -- a code repo can hold sensitive records
|
|
4048
|
-
// too -- and defaults to "standard", so an operator who just presses enter
|
|
4049
|
-
// keeps today's behaviour exactly.
|
|
4050
|
-
ask: async () => {
|
|
4051
|
-
const content_sensitivity = await select3({
|
|
4052
|
-
message: "How should agents treat this repository's contents?",
|
|
4053
|
-
choices: [
|
|
4054
|
-
{
|
|
4055
|
-
value: "standard",
|
|
4056
|
-
name: "Standard \u2014 agents may quote contents in journals and decisions"
|
|
4057
|
-
},
|
|
4058
|
-
{
|
|
4059
|
-
value: "sensitive",
|
|
4060
|
-
name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
|
|
4061
|
-
}
|
|
4062
|
-
],
|
|
4063
|
-
default: "standard"
|
|
4064
|
-
});
|
|
4065
|
-
return { content_sensitivity };
|
|
4066
|
-
}
|
|
4067
|
-
},
|
|
4068
|
-
{
|
|
4069
|
-
field: "subject_root",
|
|
4070
|
-
// EF-07. Only the docs profile is asked: other profiles describe code or a
|
|
4071
|
-
// venture portfolio, where "what is this repo about" is already answered.
|
|
4072
|
-
// A docs install that legitimately holds several subjects answers
|
|
4073
|
-
// "portfolio" and stays unset -- so this retrofit deliberately does NOT
|
|
4074
|
-
// re-ask on every update. It is gated on the field being absent AND the
|
|
4075
|
-
// profile being docs, and once answered (either way) it never fires again,
|
|
4076
|
-
// because "portfolio" writes an explicit empty marker.
|
|
4077
|
-
needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
|
|
4078
|
-
ask: async () => {
|
|
4079
|
-
const shape = await select3({
|
|
4080
|
-
message: "What does this repository hold?",
|
|
4081
|
-
choices: [
|
|
4082
|
-
{
|
|
4083
|
-
value: "root",
|
|
4084
|
-
name: "One subject, at the repository root (documents sit alongside Arcane's files)"
|
|
4085
|
-
},
|
|
4086
|
-
{ value: "subdir", name: "One subject, in its own directory" },
|
|
4087
|
-
{ value: "portfolio", name: "Several subjects or ventures" }
|
|
4088
|
-
]
|
|
4089
|
-
});
|
|
4090
|
-
if (shape === "root") return { subject_root: "." };
|
|
4091
|
-
if (shape === "subdir") {
|
|
4092
|
-
const answer = await input3({
|
|
4093
|
-
message: "Directory holding the subject's documents:",
|
|
4094
|
-
default: "docs",
|
|
4095
|
-
validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
|
|
4096
|
-
});
|
|
4097
|
-
return { subject_root: answer.trim() || "docs" };
|
|
4098
|
-
}
|
|
4099
|
-
return { subject_root: null };
|
|
4100
|
-
}
|
|
4101
|
-
},
|
|
4102
|
-
{
|
|
4103
|
-
field: "push_policy",
|
|
4104
|
-
needsRetrofit: (m) => m.push_policy === void 0,
|
|
4105
|
-
// EF-09. Defaults to "open", so an operator who just presses enter keeps
|
|
4106
|
-
// today's behaviour exactly. NOTE: this only records the choice -- unlike
|
|
4107
|
-
// init, the retrofit does not install the hook or disable the push URL,
|
|
4108
|
-
// because an update should not silently start blocking pushes in a repo
|
|
4109
|
-
// someone is mid-workflow in. doctor reports the gap instead.
|
|
4110
|
-
ask: async () => {
|
|
4111
|
-
const push_policy = await select3({
|
|
4112
|
-
message: "Should this repository be allowed to push to a remote?",
|
|
4113
|
-
choices: [
|
|
4114
|
-
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
4115
|
-
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
4116
|
-
{ value: "blocked", name: "No \u2014 block pushes" }
|
|
4117
|
-
],
|
|
4118
|
-
default: "open"
|
|
4119
|
-
});
|
|
4120
|
-
return { push_policy };
|
|
4121
|
-
}
|
|
4122
|
-
}
|
|
4123
|
-
];
|
|
4124
|
-
async function runManifestRetrofits(manifest) {
|
|
4125
|
-
const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
|
|
4126
|
-
if (applicable.length === 0) return {};
|
|
4127
|
-
console.log();
|
|
4128
|
-
console.log(
|
|
4129
|
-
`This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
|
|
4130
|
-
);
|
|
4131
|
-
let patch = {};
|
|
4132
|
-
for (const retrofit of applicable) {
|
|
4133
|
-
const answer = await retrofit.ask({ ...manifest, ...patch });
|
|
4134
|
-
patch = { ...patch, ...answer };
|
|
4135
|
-
}
|
|
4136
|
-
return patch;
|
|
4137
|
-
}
|
|
4138
|
-
async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
4139
|
-
const registryPath = join8(targetDir, businessRoot, "registry.json");
|
|
4140
|
-
if (await fileExists(registryPath)) return;
|
|
4141
|
-
const businessRootPath = join8(targetDir, businessRoot);
|
|
4142
|
-
let ventureDirs;
|
|
4143
|
-
try {
|
|
4144
|
-
const entries = await readdir6(businessRootPath, { withFileTypes: true });
|
|
4145
|
-
ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
|
|
4146
|
-
} catch {
|
|
4147
|
-
return;
|
|
4148
|
-
}
|
|
4149
|
-
if (ventureDirs.length === 0) return;
|
|
4150
|
-
console.log();
|
|
4151
|
-
const scaffold = await confirm2({
|
|
4152
|
-
message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
|
|
4153
|
-
default: true
|
|
4154
|
-
});
|
|
4155
|
-
if (!scaffold) return;
|
|
4156
|
-
const ventures = {};
|
|
4157
|
-
for (const slug of ventureDirs) {
|
|
4158
|
-
const include = await confirm2({ message: ` Include "${slug}"?`, default: true });
|
|
4159
|
-
if (!include) continue;
|
|
4160
|
-
ventures[slug] = {
|
|
4161
|
-
name: slug,
|
|
4162
|
-
aliases: [],
|
|
4163
|
-
status: "active",
|
|
4164
|
-
visibility: "private",
|
|
4165
|
-
ownership: "llc",
|
|
4166
|
-
tracking: "none",
|
|
4167
|
-
repos: []
|
|
4168
|
-
};
|
|
4169
|
-
}
|
|
4170
|
-
if (Object.keys(ventures).length === 0) {
|
|
4171
|
-
console.log(" No ventures selected -- registry not created.");
|
|
4172
|
-
return;
|
|
4173
|
-
}
|
|
4174
|
-
const registry = {
|
|
4175
|
-
_comment: "Hub-owned venture registry. Private. Never installed or modified by spell update. Seeded by the retrofit wizard -- review and enrich (aliases, ownership, tracking, clones) before relying on it.",
|
|
4176
|
-
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
4177
|
-
ventures
|
|
4178
|
-
};
|
|
4179
|
-
await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
|
|
4180
|
-
console.log(
|
|
4181
|
-
` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
|
|
4182
|
-
);
|
|
4183
|
-
}
|
|
4184
|
-
|
|
4185
4428
|
// src/modules/merge3.ts
|
|
4186
4429
|
var CONFLICT_START = "<<<<<<< yours (edited)";
|
|
4187
4430
|
var CONFLICT_MID = "=======";
|
|
@@ -4345,6 +4588,14 @@ async function missingTierWarning(scope, spellScope, homeDir) {
|
|
|
4345
4588
|
];
|
|
4346
4589
|
}
|
|
4347
4590
|
}
|
|
4591
|
+
function reportFlagPatch(flagPatch, before, after, dryRun) {
|
|
4592
|
+
console.log(`
|
|
4593
|
+
${dryRun ? "[dry-run] Would record" : "Recorded"} from flags:`);
|
|
4594
|
+
for (const line of describeFlagPatch(flagPatch)) console.log(` ${line}`);
|
|
4595
|
+
if (!dryRun && after.push_policy !== before.push_policy) {
|
|
4596
|
+
for (const line of pushPolicyNotice(after.push_policy)) console.log(line);
|
|
4597
|
+
}
|
|
4598
|
+
}
|
|
4348
4599
|
function defaultScopePatch(options, manifest) {
|
|
4349
4600
|
if (options.defaultScope === void 0) return void 0;
|
|
4350
4601
|
if (manifest.default_spell_scope === options.defaultScope) return void 0;
|
|
@@ -4353,6 +4604,9 @@ function defaultScopePatch(options, manifest) {
|
|
|
4353
4604
|
function migrateLegacyComponents(components) {
|
|
4354
4605
|
const out = [];
|
|
4355
4606
|
const seen = /* @__PURE__ */ new Set();
|
|
4607
|
+
const inheritedFiles = [];
|
|
4608
|
+
const inheritedHashes = {};
|
|
4609
|
+
let carrier = null;
|
|
4356
4610
|
for (const entry of components) {
|
|
4357
4611
|
const replacements = LEGACY_COMPONENT_MIGRATIONS[entry.name];
|
|
4358
4612
|
if (!replacements) {
|
|
@@ -4361,13 +4615,24 @@ function migrateLegacyComponents(components) {
|
|
|
4361
4615
|
out.push(entry);
|
|
4362
4616
|
continue;
|
|
4363
4617
|
}
|
|
4618
|
+
for (const file of entry.files) {
|
|
4619
|
+
if (!inheritedFiles.includes(file)) inheritedFiles.push(file);
|
|
4620
|
+
const hash = entry.fileHashes?.[file];
|
|
4621
|
+
if (hash !== void 0 && inheritedHashes[file] === void 0) {
|
|
4622
|
+
inheritedHashes[file] = hash;
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4364
4625
|
for (const name of replacements) {
|
|
4626
|
+
carrier ??= name;
|
|
4365
4627
|
if (seen.has(name)) continue;
|
|
4366
4628
|
seen.add(name);
|
|
4367
4629
|
out.push({ name, files: [], installedVersion: entry.installedVersion });
|
|
4368
4630
|
}
|
|
4369
4631
|
}
|
|
4370
|
-
return
|
|
4632
|
+
return {
|
|
4633
|
+
components: out,
|
|
4634
|
+
inherited: { files: inheritedFiles, fileHashes: inheritedHashes, carrier }
|
|
4635
|
+
};
|
|
4371
4636
|
}
|
|
4372
4637
|
async function resolveOrphan(targetDir, file, recordedHash, prune) {
|
|
4373
4638
|
try {
|
|
@@ -4392,6 +4657,19 @@ async function resolveOrphan(targetDir, file, recordedHash, prune) {
|
|
|
4392
4657
|
console.log(` Pruned orphaned file: ${file}`);
|
|
4393
4658
|
return "pruned";
|
|
4394
4659
|
}
|
|
4660
|
+
async function retainReportedOrphan(targetDir, file, recordedHash, into) {
|
|
4661
|
+
try {
|
|
4662
|
+
validateTargetPath(targetDir, file);
|
|
4663
|
+
} catch {
|
|
4664
|
+
return;
|
|
4665
|
+
}
|
|
4666
|
+
if (!await fileExists(join9(targetDir, file))) return;
|
|
4667
|
+
if (!into.files.includes(file)) into.files.push(file);
|
|
4668
|
+
if (recordedHash !== void 0) {
|
|
4669
|
+
into.fileHashes ??= {};
|
|
4670
|
+
into.fileHashes[file] = recordedHash;
|
|
4671
|
+
}
|
|
4672
|
+
}
|
|
4395
4673
|
function findReadoptedSpellComponents(components, scope = "repo", spellScope = "repo") {
|
|
4396
4674
|
if (scope !== "repo" || spellScope !== "repo") return /* @__PURE__ */ new Set();
|
|
4397
4675
|
return new Set(
|
|
@@ -4460,6 +4738,27 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4460
4738
|
return;
|
|
4461
4739
|
}
|
|
4462
4740
|
const scope = resolvedScope.scope;
|
|
4741
|
+
let flagPatch = {};
|
|
4742
|
+
if (hasManifestFlags(options.manifestFlags)) {
|
|
4743
|
+
if (scope !== "repo") {
|
|
4744
|
+
console.error(
|
|
4745
|
+
"\n \u2717 The manifest-question flags describe a repository; the user tier has none of those fields.\n"
|
|
4746
|
+
);
|
|
4747
|
+
process.exit(1);
|
|
4748
|
+
return;
|
|
4749
|
+
}
|
|
4750
|
+
try {
|
|
4751
|
+
flagPatch = resolveManifestFlags(options.manifestFlags, manifest, join9(targetDir, ".arcane.json"));
|
|
4752
|
+
} catch (err) {
|
|
4753
|
+
if (!(err instanceof ManifestFlagError)) throw err;
|
|
4754
|
+
console.error(`
|
|
4755
|
+
\u2717 ${err.message}
|
|
4756
|
+
`);
|
|
4757
|
+
process.exit(1);
|
|
4758
|
+
return;
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
const answered = { ...manifest, ...flagPatch };
|
|
4463
4762
|
const spellScope = effectiveSpellScope(manifest);
|
|
4464
4763
|
const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
|
|
4465
4764
|
const homeDir = dirname5(targetDir);
|
|
@@ -4500,6 +4799,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4500
4799
|
const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
|
|
4501
4800
|
if (missing.length === 0 && unmanaged.length === 0) {
|
|
4502
4801
|
console.log("Already up to date.");
|
|
4802
|
+
if (scope === "repo" && Object.keys(flagPatch).length > 0) {
|
|
4803
|
+
if (!options.dryRun) await writeManifest(targetDir, answered);
|
|
4804
|
+
reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
|
|
4805
|
+
}
|
|
4503
4806
|
if (scope === "user") {
|
|
4504
4807
|
const fanout = await syncUserTierFanout({
|
|
4505
4808
|
ownedClients: SPELL_FANOUT_CLIENTS,
|
|
@@ -4532,6 +4835,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4532
4835
|
}
|
|
4533
4836
|
if (manifest.components.length === 0) {
|
|
4534
4837
|
console.log("No components installed. Nothing to update.");
|
|
4838
|
+
if (scope === "repo" && Object.keys(flagPatch).length > 0) {
|
|
4839
|
+
if (!options.dryRun) await writeManifest(targetDir, answered);
|
|
4840
|
+
reportFlagPatch(flagPatch, manifest, answered, options.dryRun);
|
|
4841
|
+
}
|
|
4535
4842
|
return;
|
|
4536
4843
|
}
|
|
4537
4844
|
let fileCount = 0;
|
|
@@ -4539,7 +4846,17 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4539
4846
|
const conflictedFiles = [];
|
|
4540
4847
|
const customizedShims = [];
|
|
4541
4848
|
const orphanReport = [];
|
|
4542
|
-
const
|
|
4849
|
+
const orphanCandidates = [];
|
|
4850
|
+
const { components: componentsToUpdate, inherited } = migrateLegacyComponents(manifest.components);
|
|
4851
|
+
if (inherited.carrier !== null) {
|
|
4852
|
+
for (const file of inherited.files) {
|
|
4853
|
+
orphanCandidates.push({
|
|
4854
|
+
owner: inherited.carrier,
|
|
4855
|
+
file,
|
|
4856
|
+
recordedHash: inherited.fileHashes[file]
|
|
4857
|
+
});
|
|
4858
|
+
}
|
|
4859
|
+
}
|
|
4543
4860
|
for (const installed of componentsToUpdate) {
|
|
4544
4861
|
let component;
|
|
4545
4862
|
try {
|
|
@@ -4658,24 +4975,11 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4658
4975
|
}
|
|
4659
4976
|
for (const file of installed.files) {
|
|
4660
4977
|
if (updatedFiles.includes(file)) continue;
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
targetDir,
|
|
4978
|
+
orphanCandidates.push({
|
|
4979
|
+
owner: installed.name,
|
|
4664
4980
|
file,
|
|
4665
|
-
recordedHash
|
|
4666
|
-
|
|
4667
|
-
);
|
|
4668
|
-
orphanReport.push({ file, status });
|
|
4669
|
-
if (status === "reported") {
|
|
4670
|
-
try {
|
|
4671
|
-
validateTargetPath(targetDir, file);
|
|
4672
|
-
if (await fileExists(join9(targetDir, file))) {
|
|
4673
|
-
updatedFiles.push(file);
|
|
4674
|
-
if (recordedHash !== void 0) fileHashes[file] = recordedHash;
|
|
4675
|
-
}
|
|
4676
|
-
} catch {
|
|
4677
|
-
}
|
|
4678
|
-
}
|
|
4981
|
+
recordedHash: installed.fileHashes?.[file]
|
|
4982
|
+
});
|
|
4679
4983
|
}
|
|
4680
4984
|
updatedComponents.push({
|
|
4681
4985
|
...installed,
|
|
@@ -4684,6 +4988,26 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4684
4988
|
fileHashes
|
|
4685
4989
|
});
|
|
4686
4990
|
}
|
|
4991
|
+
const claimed = new Set(updatedComponents.flatMap((c) => c.files));
|
|
4992
|
+
const resolvedOrphans = /* @__PURE__ */ new Set();
|
|
4993
|
+
for (const { owner, file, recordedHash } of orphanCandidates) {
|
|
4994
|
+
if (claimed.has(file) || resolvedOrphans.has(file)) continue;
|
|
4995
|
+
resolvedOrphans.add(file);
|
|
4996
|
+
const status = await resolveOrphan(
|
|
4997
|
+
targetDir,
|
|
4998
|
+
file,
|
|
4999
|
+
recordedHash,
|
|
5000
|
+
Boolean(options.prune) && !options.dryRun
|
|
5001
|
+
);
|
|
5002
|
+
orphanReport.push({ file, status });
|
|
5003
|
+
if (status !== "reported") continue;
|
|
5004
|
+
let into = updatedComponents.find((c) => c.name === owner);
|
|
5005
|
+
if (!into) {
|
|
5006
|
+
into = { name: owner, files: [], installedVersion: packageVersion, fileHashes: {} };
|
|
5007
|
+
updatedComponents.push(into);
|
|
5008
|
+
}
|
|
5009
|
+
await retainReportedOrphan(targetDir, file, recordedHash, into);
|
|
5010
|
+
}
|
|
4687
5011
|
const orphansToReport = orphanReport.filter((o) => o.status !== "not-found");
|
|
4688
5012
|
if (orphansToReport.length > 0) {
|
|
4689
5013
|
const verb = options.dryRun ? "[dry-run] Found" : "Found";
|
|
@@ -4736,7 +5060,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4736
5060
|
`
|
|
4737
5061
|
[dry-run] Would update ${fileCount} files.`
|
|
4738
5062
|
);
|
|
4739
|
-
|
|
5063
|
+
if (Object.keys(flagPatch).length > 0) reportFlagPatch(flagPatch, manifest, answered, true);
|
|
5064
|
+
const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered)) : [];
|
|
4740
5065
|
if (applicableRetrofits.length > 0) {
|
|
4741
5066
|
console.log(
|
|
4742
5067
|
`[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
|
|
@@ -4745,19 +5070,21 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4745
5070
|
return;
|
|
4746
5071
|
}
|
|
4747
5072
|
const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
|
|
4748
|
-
const retrofitPatch = interactive ? await runManifestRetrofits(
|
|
5073
|
+
const retrofitPatch = interactive ? await runManifestRetrofits(answered, targetDir) : {};
|
|
4749
5074
|
if (!interactive && scope === "repo") {
|
|
4750
|
-
const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(
|
|
5075
|
+
const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered));
|
|
4751
5076
|
if (pending.length > 0) {
|
|
4752
5077
|
console.log(
|
|
4753
|
-
` ! Skipped ${pending.length} manifest question${pending.length === 1 ? "" : "s"} (${pending.map((r) => r.field).join(", ")}) \u2014 no interactive terminal. Run \`spell update\` from a terminal
|
|
5078
|
+
` ! Skipped ${pending.length} manifest question${pending.length === 1 ? "" : "s"} (${pending.map((r) => r.field).join(", ")}) \u2014 no interactive terminal. Run \`spell update\` from a terminal, or answer by flags:`
|
|
4754
5079
|
);
|
|
5080
|
+
for (const line of manifestFlagLine(answered) ?? []) console.log(` ${line}`);
|
|
4755
5081
|
}
|
|
4756
5082
|
}
|
|
4757
5083
|
const updated = {
|
|
4758
5084
|
...manifest,
|
|
4759
5085
|
version: packageVersion,
|
|
4760
5086
|
components: updatedComponents,
|
|
5087
|
+
...flagPatch,
|
|
4761
5088
|
...retrofitPatch,
|
|
4762
5089
|
...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {},
|
|
4763
5090
|
...defaultScopePatch(options, manifest)
|
|
@@ -4767,6 +5094,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4767
5094
|
`
|
|
4768
5095
|
\u2713 Updated ${fileCount} files.`
|
|
4769
5096
|
);
|
|
5097
|
+
if (Object.keys(flagPatch).length > 0) {
|
|
5098
|
+
reportFlagPatch(flagPatch, manifest, updated, false);
|
|
5099
|
+
} else if (updated.push_policy !== manifest.push_policy) {
|
|
5100
|
+
for (const line of pushPolicyNotice(updated.push_policy)) console.log(line);
|
|
5101
|
+
}
|
|
4770
5102
|
if (conflictedFiles.length > 0) {
|
|
4771
5103
|
console.log(
|
|
4772
5104
|
`
|
|
@@ -4776,7 +5108,13 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4776
5108
|
);
|
|
4777
5109
|
}
|
|
4778
5110
|
if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
|
|
4779
|
-
|
|
5111
|
+
if (process.stdin.isTTY) {
|
|
5112
|
+
await offerRegistryScaffold(targetDir, updated.business_root ?? "ventures");
|
|
5113
|
+
} else {
|
|
5114
|
+
console.log(
|
|
5115
|
+
` This repository is now a hub. Run \`spell update\` from a terminal to scaffold ${updated.business_root ?? "ventures"}/registry.json from existing venture folders.`
|
|
5116
|
+
);
|
|
5117
|
+
}
|
|
4780
5118
|
}
|
|
4781
5119
|
}
|
|
4782
5120
|
|
|
@@ -5187,10 +5525,8 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
5187
5525
|
}
|
|
5188
5526
|
|
|
5189
5527
|
// src/commands/doctor.ts
|
|
5190
|
-
import { access as access4, readdir as
|
|
5191
|
-
import { join as
|
|
5192
|
-
import { execFile as execFile3 } from "child_process";
|
|
5193
|
-
import { promisify as promisify2 } from "util";
|
|
5528
|
+
import { access as access4, readdir as readdir9, readFile as readFile11, mkdir as mkdir7, copyFile as fsCopyFile } from "fs/promises";
|
|
5529
|
+
import { join as join14, dirname as dirname9 } from "path";
|
|
5194
5530
|
|
|
5195
5531
|
// src/config/incidents.ts
|
|
5196
5532
|
var INCIDENT_QUEUE = [
|
|
@@ -5331,10 +5667,33 @@ async function scanRepository(repoRoot, rules, extensions, excludePrefixes = [])
|
|
|
5331
5667
|
return findings;
|
|
5332
5668
|
}
|
|
5333
5669
|
|
|
5334
|
-
// src/modules/
|
|
5670
|
+
// src/modules/exec.ts
|
|
5335
5671
|
import { execFile as execFile2 } from "child_process";
|
|
5336
|
-
|
|
5337
|
-
|
|
5672
|
+
var EXTERNAL_CLI_TIMEOUT_MS = 1e4;
|
|
5673
|
+
function execFileWithTimeout(file, args, timeoutMs) {
|
|
5674
|
+
return new Promise((resolve5, reject) => {
|
|
5675
|
+
const child = execFile2(
|
|
5676
|
+
file,
|
|
5677
|
+
args,
|
|
5678
|
+
{
|
|
5679
|
+
timeout: timeoutMs,
|
|
5680
|
+
killSignal: "SIGTERM",
|
|
5681
|
+
windowsHide: true,
|
|
5682
|
+
encoding: "utf8"
|
|
5683
|
+
},
|
|
5684
|
+
(error, stdout, stderr) => {
|
|
5685
|
+
if (error) {
|
|
5686
|
+
reject(error);
|
|
5687
|
+
return;
|
|
5688
|
+
}
|
|
5689
|
+
resolve5({ stdout, stderr });
|
|
5690
|
+
}
|
|
5691
|
+
);
|
|
5692
|
+
child.stdin?.end();
|
|
5693
|
+
});
|
|
5694
|
+
}
|
|
5695
|
+
|
|
5696
|
+
// src/modules/platform-policy.ts
|
|
5338
5697
|
var SANCTIONED_MERGE_METHODS = ["merge", "rebase"];
|
|
5339
5698
|
function compareToLadder(effective, declaredButBlocked, interactionNote) {
|
|
5340
5699
|
const disallowedButPermitted = effective.filter(
|
|
@@ -5401,17 +5760,19 @@ function evaluateGitHubMergePolicy(rulesets) {
|
|
|
5401
5760
|
}
|
|
5402
5761
|
async function fetchGitHubRulesets(owner, repo) {
|
|
5403
5762
|
try {
|
|
5404
|
-
const { stdout: listOut } = await
|
|
5405
|
-
"
|
|
5406
|
-
`repos/${owner}/${repo}/rulesets`
|
|
5407
|
-
|
|
5763
|
+
const { stdout: listOut } = await execFileWithTimeout(
|
|
5764
|
+
"gh",
|
|
5765
|
+
["api", `repos/${owner}/${repo}/rulesets`],
|
|
5766
|
+
EXTERNAL_CLI_TIMEOUT_MS
|
|
5767
|
+
);
|
|
5408
5768
|
const summaries = JSON.parse(listOut);
|
|
5409
5769
|
const detailed = await Promise.all(
|
|
5410
5770
|
summaries.map(async (s) => {
|
|
5411
|
-
const { stdout } = await
|
|
5412
|
-
"
|
|
5413
|
-
`repos/${owner}/${repo}/rulesets/${s.id}`
|
|
5414
|
-
|
|
5771
|
+
const { stdout } = await execFileWithTimeout(
|
|
5772
|
+
"gh",
|
|
5773
|
+
["api", `repos/${owner}/${repo}/rulesets/${s.id}`],
|
|
5774
|
+
EXTERNAL_CLI_TIMEOUT_MS
|
|
5775
|
+
);
|
|
5415
5776
|
return JSON.parse(stdout);
|
|
5416
5777
|
})
|
|
5417
5778
|
);
|
|
@@ -5443,7 +5804,7 @@ function evaluateAdoMergePolicy(policies) {
|
|
|
5443
5804
|
async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
|
|
5444
5805
|
try {
|
|
5445
5806
|
const orgUrl = `https://dev.azure.com/${org}`;
|
|
5446
|
-
const { stdout: repoOut } = await
|
|
5807
|
+
const { stdout: repoOut } = await execFileWithTimeout("az", [
|
|
5447
5808
|
"repos",
|
|
5448
5809
|
"show",
|
|
5449
5810
|
"--repository",
|
|
@@ -5456,10 +5817,10 @@ async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
|
|
|
5456
5817
|
"id",
|
|
5457
5818
|
"--output",
|
|
5458
5819
|
"tsv"
|
|
5459
|
-
]);
|
|
5820
|
+
], EXTERNAL_CLI_TIMEOUT_MS);
|
|
5460
5821
|
const repositoryId = repoOut.trim();
|
|
5461
5822
|
if (!repositoryId) return null;
|
|
5462
|
-
const { stdout } = await
|
|
5823
|
+
const { stdout } = await execFileWithTimeout("az", [
|
|
5463
5824
|
"repos",
|
|
5464
5825
|
"policy",
|
|
5465
5826
|
"list",
|
|
@@ -5473,7 +5834,7 @@ async function fetchAdoMergeTypePolicies(org, project, repoName, branch) {
|
|
|
5473
5834
|
project,
|
|
5474
5835
|
"--output",
|
|
5475
5836
|
"json"
|
|
5476
|
-
]);
|
|
5837
|
+
], EXTERNAL_CLI_TIMEOUT_MS);
|
|
5477
5838
|
return JSON.parse(stdout);
|
|
5478
5839
|
} catch {
|
|
5479
5840
|
return null;
|
|
@@ -5500,8 +5861,134 @@ function parseAdoRemote(url) {
|
|
|
5500
5861
|
return null;
|
|
5501
5862
|
}
|
|
5502
5863
|
|
|
5864
|
+
// src/modules/placeholders.ts
|
|
5865
|
+
import { readdir as readdir8, readFile as readFile10 } from "fs/promises";
|
|
5866
|
+
import { join as join13 } from "path";
|
|
5867
|
+
var PLACEHOLDER_STANDARD_FILE = "spell-authoring-standards.md";
|
|
5868
|
+
var RUNTIME_PLACEHOLDERS_START = "<!-- runtime-placeholders:start -->";
|
|
5869
|
+
var RUNTIME_PLACEHOLDERS_END = "<!-- runtime-placeholders:end -->";
|
|
5870
|
+
var PLACEHOLDER_PATTERN = /(?<!\$)\{([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)\}/g;
|
|
5871
|
+
var FRONTMATTER_PATTERN2 = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
5872
|
+
function parseFrontmatterStatus(content) {
|
|
5873
|
+
const block = FRONTMATTER_PATTERN2.exec(content);
|
|
5874
|
+
if (!block) return null;
|
|
5875
|
+
const status = /^status:[ \t]*["']?([^"'\s#]+)/m.exec(block[1]);
|
|
5876
|
+
return status ? status[1] : null;
|
|
5877
|
+
}
|
|
5878
|
+
function stripFrontmatter(content) {
|
|
5879
|
+
const block = FRONTMATTER_PATTERN2.exec(content);
|
|
5880
|
+
return block ? content.slice(block[0].length) : content;
|
|
5881
|
+
}
|
|
5882
|
+
function stripFencedCode(text) {
|
|
5883
|
+
const kept = [];
|
|
5884
|
+
let fence = null;
|
|
5885
|
+
for (const line of text.split(/\r?\n/)) {
|
|
5886
|
+
if (fence) {
|
|
5887
|
+
const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
5888
|
+
if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
|
|
5889
|
+
kept.push("");
|
|
5890
|
+
continue;
|
|
5891
|
+
}
|
|
5892
|
+
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
5893
|
+
if (open && !(open[1][0] === "`" && line.slice(open[0].length).includes("`"))) {
|
|
5894
|
+
fence = { char: open[1][0], length: open[1].length };
|
|
5895
|
+
kept.push("");
|
|
5896
|
+
continue;
|
|
5897
|
+
}
|
|
5898
|
+
kept.push(line);
|
|
5899
|
+
}
|
|
5900
|
+
return kept.join("\n");
|
|
5901
|
+
}
|
|
5902
|
+
function stripInlineCode(text) {
|
|
5903
|
+
return text.split(/\n[ \t]*\n/).map((paragraph) => {
|
|
5904
|
+
let out = "";
|
|
5905
|
+
let i = 0;
|
|
5906
|
+
while (i < paragraph.length) {
|
|
5907
|
+
if (paragraph[i] !== "`") {
|
|
5908
|
+
out += paragraph[i];
|
|
5909
|
+
i += 1;
|
|
5910
|
+
continue;
|
|
5911
|
+
}
|
|
5912
|
+
let runEnd = i;
|
|
5913
|
+
while (paragraph[runEnd] === "`") runEnd += 1;
|
|
5914
|
+
const run = runEnd - i;
|
|
5915
|
+
let close = -1;
|
|
5916
|
+
let j = runEnd;
|
|
5917
|
+
while (j < paragraph.length) {
|
|
5918
|
+
if (paragraph[j] !== "`") {
|
|
5919
|
+
j += 1;
|
|
5920
|
+
continue;
|
|
5921
|
+
}
|
|
5922
|
+
let k = j;
|
|
5923
|
+
while (paragraph[k] === "`") k += 1;
|
|
5924
|
+
if (k - j === run) {
|
|
5925
|
+
close = j;
|
|
5926
|
+
break;
|
|
5927
|
+
}
|
|
5928
|
+
j = k;
|
|
5929
|
+
}
|
|
5930
|
+
if (close === -1) {
|
|
5931
|
+
out += paragraph.slice(i, runEnd);
|
|
5932
|
+
i = runEnd;
|
|
5933
|
+
} else {
|
|
5934
|
+
out += " ";
|
|
5935
|
+
i = close + run;
|
|
5936
|
+
}
|
|
5937
|
+
}
|
|
5938
|
+
return out;
|
|
5939
|
+
}).join("\n\n");
|
|
5940
|
+
}
|
|
5941
|
+
function findPlaceholderTokens(content) {
|
|
5942
|
+
const prose = stripInlineCode(stripFencedCode(stripFrontmatter(content)));
|
|
5943
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5944
|
+
for (const match of prose.matchAll(PLACEHOLDER_PATTERN)) seen.add(`{${match[1]}}`);
|
|
5945
|
+
return [...seen];
|
|
5946
|
+
}
|
|
5947
|
+
function parseRuntimePlaceholders(standardContent) {
|
|
5948
|
+
const start = standardContent.indexOf(RUNTIME_PLACEHOLDERS_START);
|
|
5949
|
+
const end = standardContent.indexOf(RUNTIME_PLACEHOLDERS_END);
|
|
5950
|
+
if (start === -1 || end === -1 || end < start) return null;
|
|
5951
|
+
const region = standardContent.slice(start + RUNTIME_PLACEHOLDERS_START.length, end);
|
|
5952
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
5953
|
+
for (const match of region.matchAll(/^[ \t]*[-*][ \t]+`(\{[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*\})`/gm)) {
|
|
5954
|
+
tokens.add(match[1]);
|
|
5955
|
+
}
|
|
5956
|
+
return tokens;
|
|
5957
|
+
}
|
|
5958
|
+
async function scanGovernancePlaceholders(governanceDir) {
|
|
5959
|
+
let standard;
|
|
5960
|
+
try {
|
|
5961
|
+
standard = await readFile10(join13(governanceDir, PLACEHOLDER_STANDARD_FILE), "utf8");
|
|
5962
|
+
} catch (err) {
|
|
5963
|
+
if (err.code === "ENOENT") {
|
|
5964
|
+
return { status: "skipped", reason: `${PLACEHOLDER_STANDARD_FILE} is not installed` };
|
|
5965
|
+
}
|
|
5966
|
+
throw err;
|
|
5967
|
+
}
|
|
5968
|
+
const runtimeTokens = parseRuntimePlaceholders(standard);
|
|
5969
|
+
if (!runtimeTokens) {
|
|
5970
|
+
return {
|
|
5971
|
+
status: "skipped",
|
|
5972
|
+
reason: `${PLACEHOLDER_STANDARD_FILE} has no runtime-placeholders list (an older version?)`
|
|
5973
|
+
};
|
|
5974
|
+
}
|
|
5975
|
+
const files = (await readdir8(governanceDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name).sort();
|
|
5976
|
+
const activeDocs = [];
|
|
5977
|
+
const templateDocs = [];
|
|
5978
|
+
const findings = [];
|
|
5979
|
+
for (const file of files) {
|
|
5980
|
+
const content = await readFile10(join13(governanceDir, file), "utf8");
|
|
5981
|
+
const status = parseFrontmatterStatus(content);
|
|
5982
|
+
if (status === "template") templateDocs.push(file);
|
|
5983
|
+
if (status !== "active") continue;
|
|
5984
|
+
activeDocs.push(file);
|
|
5985
|
+
const unknown = findPlaceholderTokens(content).filter((token) => !runtimeTokens.has(token));
|
|
5986
|
+
if (unknown.length > 0) findings.push({ file, tokens: unknown });
|
|
5987
|
+
}
|
|
5988
|
+
return { status: "scanned", runtimeTokens, activeDocs, templateDocs, findings };
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5503
5991
|
// src/commands/doctor.ts
|
|
5504
|
-
var execFileAsync2 = promisify2(execFile3);
|
|
5505
5992
|
function isRecord3(value) {
|
|
5506
5993
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5507
5994
|
}
|
|
@@ -5518,52 +6005,45 @@ async function checkNodeVersion() {
|
|
|
5518
6005
|
message: `${raw} \u2014 Node.js 18 or later is required`
|
|
5519
6006
|
};
|
|
5520
6007
|
}
|
|
5521
|
-
async function checkVSCodeExtension(id, label) {
|
|
6008
|
+
async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
|
|
5522
6009
|
const name = `VS Code extension: ${label}`;
|
|
5523
|
-
const
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
join13(home, ".vscode-insiders", "extensions")
|
|
6010
|
+
const flavours = [
|
|
6011
|
+
{ dir: join14(homeDir, ".vscode", "extensions"), bin: "code" },
|
|
6012
|
+
{ dir: join14(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
|
|
5527
6013
|
];
|
|
5528
|
-
|
|
6014
|
+
let installBin = null;
|
|
6015
|
+
for (const flavour of flavours) {
|
|
6016
|
+
let entries;
|
|
5529
6017
|
try {
|
|
5530
|
-
|
|
5531
|
-
const entries = await readdir11(dir);
|
|
5532
|
-
const prefix = id.toLowerCase() + "-";
|
|
5533
|
-
const found = entries.some(
|
|
5534
|
-
(e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
|
|
5535
|
-
);
|
|
5536
|
-
if (found) {
|
|
5537
|
-
return { name, passed: true, message: `${id} is installed` };
|
|
5538
|
-
}
|
|
6018
|
+
entries = await readdir9(flavour.dir);
|
|
5539
6019
|
} catch {
|
|
6020
|
+
continue;
|
|
5540
6021
|
}
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
} catch {
|
|
6022
|
+
installBin ??= flavour.bin;
|
|
6023
|
+
const prefix = id.toLowerCase() + "-";
|
|
6024
|
+
const found = entries.some(
|
|
6025
|
+
(e) => e.toLowerCase() === id.toLowerCase() || e.toLowerCase().startsWith(prefix)
|
|
6026
|
+
);
|
|
6027
|
+
if (found) {
|
|
6028
|
+
return { name, passed: true, message: `${id} is installed` };
|
|
5549
6029
|
}
|
|
5550
6030
|
}
|
|
5551
6031
|
return {
|
|
5552
6032
|
name,
|
|
5553
6033
|
passed: false,
|
|
5554
|
-
message: `${id} not found. Install via: ${installBin} --install-extension ${id}`,
|
|
6034
|
+
message: `${id} not found. Install via: ${installBin ?? "code"} --install-extension ${id}`,
|
|
5555
6035
|
blocking: false
|
|
5556
6036
|
};
|
|
5557
6037
|
}
|
|
5558
6038
|
async function checkArcaneManifest(targetDir) {
|
|
5559
6039
|
const name = "Arcane manifest (.arcane.json)";
|
|
5560
|
-
const manifestPath2 =
|
|
6040
|
+
const manifestPath2 = join14(targetDir, ".arcane.json");
|
|
5561
6041
|
try {
|
|
5562
6042
|
await access4(manifestPath2);
|
|
5563
6043
|
} catch {
|
|
5564
|
-
const selfHostedManifestPath =
|
|
6044
|
+
const selfHostedManifestPath = join14(targetDir, "src", "assets", ".arcane.json");
|
|
5565
6045
|
try {
|
|
5566
|
-
const raw = await
|
|
6046
|
+
const raw = await readFile11(selfHostedManifestPath, "utf8");
|
|
5567
6047
|
const manifest = JSON.parse(raw);
|
|
5568
6048
|
if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
|
|
5569
6049
|
return {
|
|
@@ -5582,7 +6062,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
5582
6062
|
};
|
|
5583
6063
|
}
|
|
5584
6064
|
try {
|
|
5585
|
-
const raw = await
|
|
6065
|
+
const raw = await readFile11(manifestPath2, "utf8");
|
|
5586
6066
|
const manifest = JSON.parse(raw);
|
|
5587
6067
|
if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
|
|
5588
6068
|
return {
|
|
@@ -5667,7 +6147,7 @@ async function checkSessionContinuity(targetDir) {
|
|
|
5667
6147
|
const results = [];
|
|
5668
6148
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
5669
6149
|
const name = `Session file: ${file}`;
|
|
5670
|
-
const filePath =
|
|
6150
|
+
const filePath = join14(targetDir, file);
|
|
5671
6151
|
try {
|
|
5672
6152
|
await access4(filePath);
|
|
5673
6153
|
results.push({ name, passed: true, message: "present" });
|
|
@@ -5685,11 +6165,11 @@ async function checkSessionContinuity(targetDir) {
|
|
|
5685
6165
|
async function fixSessionContinuity(targetDir, assetsDir) {
|
|
5686
6166
|
const created = [];
|
|
5687
6167
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
5688
|
-
const destPath =
|
|
6168
|
+
const destPath = join14(targetDir, file);
|
|
5689
6169
|
try {
|
|
5690
6170
|
await access4(destPath);
|
|
5691
6171
|
} catch {
|
|
5692
|
-
const srcPath =
|
|
6172
|
+
const srcPath = join14(assetsDir, file);
|
|
5693
6173
|
await mkdir7(dirname9(destPath), { recursive: true });
|
|
5694
6174
|
await fsCopyFile(srcPath, destPath);
|
|
5695
6175
|
created.push(file);
|
|
@@ -5809,15 +6289,48 @@ async function checkPushPolicy(targetDir) {
|
|
|
5809
6289
|
name,
|
|
5810
6290
|
passed: false,
|
|
5811
6291
|
blocking: false,
|
|
5812
|
-
|
|
6292
|
+
// ARC-049 decision 3: name the command that closes the gap.
|
|
6293
|
+
message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have. Run \`spell block-push\` to install the controls.`
|
|
6294
|
+
};
|
|
6295
|
+
}
|
|
6296
|
+
async function checkGovernancePlaceholders(targetDir) {
|
|
6297
|
+
const name = "Governance placeholders (ARC-051)";
|
|
6298
|
+
let scan;
|
|
6299
|
+
try {
|
|
6300
|
+
scan = await scanGovernancePlaceholders(join14(targetDir, ".arcane", "governance"));
|
|
6301
|
+
} catch (err) {
|
|
6302
|
+
return {
|
|
6303
|
+
name,
|
|
6304
|
+
passed: false,
|
|
6305
|
+
blocking: false,
|
|
6306
|
+
message: `could not scan .arcane/governance (${err instanceof Error ? err.message : String(err)})`
|
|
6307
|
+
};
|
|
6308
|
+
}
|
|
6309
|
+
if (scan.status === "skipped") {
|
|
6310
|
+
return { name, passed: true, blocking: false, skipped: true, message: `skipped \u2014 ${scan.reason}` };
|
|
6311
|
+
}
|
|
6312
|
+
if (scan.findings.length === 0) {
|
|
6313
|
+
return {
|
|
6314
|
+
name,
|
|
6315
|
+
passed: true,
|
|
6316
|
+
blocking: false,
|
|
6317
|
+
message: `${scan.activeDocs.length} active doc(s) scanned, no unknown placeholders`
|
|
6318
|
+
};
|
|
6319
|
+
}
|
|
6320
|
+
const listed = scan.findings.map(({ file, tokens }) => `.arcane/governance/${file}: ${tokens.join(", ")}`);
|
|
6321
|
+
return {
|
|
6322
|
+
name,
|
|
6323
|
+
passed: false,
|
|
6324
|
+
blocking: false,
|
|
6325
|
+
message: `unknown placeholder(s) in status: active doc(s) \u2014 ${listed.join("; ")}. Fill each one in; or, if an agent resolves it at use time, add it to the runtime-placeholders list in .arcane/governance/${PLACEHOLDER_STANDARD_FILE}; or, if the document is meant to be filled in by hand, mark it \`status: template\`.`
|
|
5813
6326
|
};
|
|
5814
6327
|
}
|
|
5815
6328
|
async function checkMcpConfig(targetDir) {
|
|
5816
6329
|
const name = "MCP config (per-server timeout, I12)";
|
|
5817
|
-
const filePath =
|
|
6330
|
+
const filePath = join14(targetDir, ".mcp.json");
|
|
5818
6331
|
let raw;
|
|
5819
6332
|
try {
|
|
5820
|
-
raw = await
|
|
6333
|
+
raw = await readFile11(filePath, "utf8");
|
|
5821
6334
|
} catch {
|
|
5822
6335
|
return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
|
|
5823
6336
|
}
|
|
@@ -5870,10 +6383,10 @@ function isDelegationsFile(value) {
|
|
|
5870
6383
|
}
|
|
5871
6384
|
async function checkDelegations(targetDir) {
|
|
5872
6385
|
const name = "Standing delegations (T13)";
|
|
5873
|
-
const filePath =
|
|
6386
|
+
const filePath = join14(targetDir, ".arcane", "delegations.json");
|
|
5874
6387
|
let raw;
|
|
5875
6388
|
try {
|
|
5876
|
-
raw = await
|
|
6389
|
+
raw = await readFile11(filePath, "utf8");
|
|
5877
6390
|
} catch {
|
|
5878
6391
|
return { name, passed: true, blocking: false, message: "no delegations recorded" };
|
|
5879
6392
|
}
|
|
@@ -5983,7 +6496,7 @@ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
|
|
|
5983
6496
|
let probed = false;
|
|
5984
6497
|
const probe = "spell-status.md";
|
|
5985
6498
|
try {
|
|
5986
|
-
const entries = await
|
|
6499
|
+
const entries = await readdir9(join14(storeRoot, USER_TIER_SPELLS_DIR));
|
|
5987
6500
|
const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
|
|
5988
6501
|
spellCount = spells.length;
|
|
5989
6502
|
probed = spells.includes(probe);
|
|
@@ -6037,7 +6550,7 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
6037
6550
|
}
|
|
6038
6551
|
const results = await Promise.all([
|
|
6039
6552
|
checkNodeVersion(),
|
|
6040
|
-
checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
|
|
6553
|
+
checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)", resolveHomeDir()),
|
|
6041
6554
|
checkArcaneManifest(targetDir),
|
|
6042
6555
|
Promise.resolve(checkIncidentReleaseGate()),
|
|
6043
6556
|
checkPullRebase(targetDir),
|
|
@@ -6046,16 +6559,18 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
6046
6559
|
checkDelegations(targetDir),
|
|
6047
6560
|
checkMcpConfig(targetDir),
|
|
6048
6561
|
checkUserTier(resolveHomeDir(), options.packageVersion),
|
|
6049
|
-
checkSpellScope(targetDir)
|
|
6562
|
+
checkSpellScope(targetDir),
|
|
6563
|
+
checkGovernancePlaceholders(targetDir)
|
|
6050
6564
|
]);
|
|
6051
6565
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
6052
6566
|
results.push(...sessionResults);
|
|
6053
6567
|
let allPassed = true;
|
|
6054
6568
|
for (const result of results) {
|
|
6055
|
-
const
|
|
6056
|
-
const
|
|
6569
|
+
const skipped = result.passed && result.skipped === true;
|
|
6570
|
+
const icon = skipped ? "\u2013" : result.passed ? "\u2713" : result.blocking === false ? "\u26A0" : "\u2717";
|
|
6571
|
+
const label = skipped ? "skip" : result.passed ? "pass" : result.blocking === false ? "warn" : "FAIL";
|
|
6057
6572
|
console.log(` ${icon} [${label}] ${result.name}`);
|
|
6058
|
-
if (!result.passed) {
|
|
6573
|
+
if (!result.passed || skipped) {
|
|
6059
6574
|
console.log(` ${result.message}`);
|
|
6060
6575
|
}
|
|
6061
6576
|
if (!result.passed && result.blocking !== false) {
|
|
@@ -6067,40 +6582,38 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
6067
6582
|
console.log(" All checks passed. Your environment is ready.\n");
|
|
6068
6583
|
} else {
|
|
6069
6584
|
console.log(" One or more checks failed. Fix the issues above before proceeding.\n");
|
|
6070
|
-
|
|
6071
|
-
|
|
6072
|
-
|
|
6073
|
-
|
|
6074
|
-
|
|
6075
|
-
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
console.log(`
|
|
6585
|
+
process.exitCode = 1;
|
|
6586
|
+
}
|
|
6587
|
+
const hasMissingSession = sessionResults.some((r) => !r.passed);
|
|
6588
|
+
if (options.fix && hasMissingSession && assetsDir) {
|
|
6589
|
+
console.log(" \u{1F527} Fixing missing session continuity files...\n");
|
|
6590
|
+
const created = await fixSessionContinuity(targetDir, assetsDir);
|
|
6591
|
+
if (created.length > 0) {
|
|
6592
|
+
for (const file of created) {
|
|
6593
|
+
console.log(` \u2713 Created: ${file}`);
|
|
6594
|
+
}
|
|
6595
|
+
console.log(`
|
|
6079
6596
|
${created.length} file(s) created. Repo is now close-session-ready.
|
|
6080
6597
|
`);
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
}
|
|
6084
|
-
} else if (hasMissingSession && !options.fix) {
|
|
6085
|
-
console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
|
|
6086
|
-
}
|
|
6087
|
-
if (!allPassed) {
|
|
6088
|
-
process.exitCode = 1;
|
|
6598
|
+
} else {
|
|
6599
|
+
console.log(" No files needed creation (all present).\n");
|
|
6089
6600
|
}
|
|
6601
|
+
} else if (hasMissingSession && !options.fix) {
|
|
6602
|
+
console.log(" \u{1F4A1} Run `spell doctor --fix` to create missing session files.\n");
|
|
6090
6603
|
}
|
|
6091
6604
|
}
|
|
6092
6605
|
|
|
6093
6606
|
// src/commands/ward.ts
|
|
6094
|
-
import { readFile as
|
|
6607
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
6095
6608
|
|
|
6096
6609
|
// src/modules/ward.ts
|
|
6097
|
-
import { readdir as
|
|
6098
|
-
import { join as
|
|
6610
|
+
import { readdir as readdir10, readFile as readFile12 } from "fs/promises";
|
|
6611
|
+
import { join as join15, relative as relative2 } from "path";
|
|
6099
6612
|
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
6100
6613
|
const findings = [];
|
|
6101
6614
|
let content;
|
|
6102
6615
|
try {
|
|
6103
|
-
content = await
|
|
6616
|
+
content = await readFile12(absolutePath, "utf8");
|
|
6104
6617
|
} catch {
|
|
6105
6618
|
return findings;
|
|
6106
6619
|
}
|
|
@@ -6153,12 +6666,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
|
|
|
6153
6666
|
const findings = [];
|
|
6154
6667
|
let entries;
|
|
6155
6668
|
try {
|
|
6156
|
-
entries = await
|
|
6669
|
+
entries = await readdir10(root, { withFileTypes: true });
|
|
6157
6670
|
} catch {
|
|
6158
6671
|
return findings;
|
|
6159
6672
|
}
|
|
6160
6673
|
for (const entry of entries) {
|
|
6161
|
-
const absolutePath =
|
|
6674
|
+
const absolutePath = join15(root, entry.name);
|
|
6162
6675
|
if (entry.isDirectory()) {
|
|
6163
6676
|
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
6164
6677
|
findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
|
|
@@ -6195,7 +6708,7 @@ async function runWard(repoRoot, options = {}) {
|
|
|
6195
6708
|
const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
|
|
6196
6709
|
if (findings2.length > 0) {
|
|
6197
6710
|
try {
|
|
6198
|
-
fileLines.set(displayPath, (await
|
|
6711
|
+
fileLines.set(displayPath, (await readFile12(absolutePath, "utf8")).split("\n"));
|
|
6199
6712
|
} catch {
|
|
6200
6713
|
}
|
|
6201
6714
|
}
|
|
@@ -6223,7 +6736,7 @@ function parseTermsFileContent(raw) {
|
|
|
6223
6736
|
}
|
|
6224
6737
|
async function readTermsFile(filePath) {
|
|
6225
6738
|
try {
|
|
6226
|
-
return parseTermsFileContent(await
|
|
6739
|
+
return parseTermsFileContent(await readFile13(filePath, "utf8"));
|
|
6227
6740
|
} catch (error) {
|
|
6228
6741
|
console.warn(
|
|
6229
6742
|
`\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
|
|
@@ -6370,17 +6883,80 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
|
6370
6883
|
);
|
|
6371
6884
|
}
|
|
6372
6885
|
|
|
6886
|
+
// src/commands/block-push.ts
|
|
6887
|
+
async function runBlockPush(targetDir) {
|
|
6888
|
+
let manifest;
|
|
6889
|
+
try {
|
|
6890
|
+
manifest = await readManifest(targetDir);
|
|
6891
|
+
} catch (err) {
|
|
6892
|
+
if (err instanceof ManifestNotFoundError) {
|
|
6893
|
+
console.error('Not initialized. Run "spell init" first.');
|
|
6894
|
+
process.exit(1);
|
|
6895
|
+
return;
|
|
6896
|
+
}
|
|
6897
|
+
throw err;
|
|
6898
|
+
}
|
|
6899
|
+
if (manifest.scope === "user") {
|
|
6900
|
+
console.error(
|
|
6901
|
+
"spell block-push applies to a repository, not the user tier: push_policy describes a repository."
|
|
6902
|
+
);
|
|
6903
|
+
process.exit(1);
|
|
6904
|
+
return;
|
|
6905
|
+
}
|
|
6906
|
+
if ((await inspectGitRepository(targetDir)).status === "not-repository") {
|
|
6907
|
+
console.error("spell block-push needs a Git repository: there is nothing here to block.");
|
|
6908
|
+
process.exit(1);
|
|
6909
|
+
return;
|
|
6910
|
+
}
|
|
6911
|
+
const recorded = manifest.push_policy ?? "open";
|
|
6912
|
+
const alreadyEnforced = recorded === "blocked" && await isHookEnforced(targetDir) && (await undisabledRemotes(targetDir)).length === 0;
|
|
6913
|
+
if (alreadyEnforced) {
|
|
6914
|
+
printSuccess("Push is already blocked and enforced here. Nothing changed.");
|
|
6915
|
+
if ((await listRemotes(targetDir)).length === 0) {
|
|
6916
|
+
printWarning(
|
|
6917
|
+
"No remote is configured, so only the pre-push hook is active. Re-run `spell block-push` after adding a remote to disable its push URL too."
|
|
6918
|
+
);
|
|
6919
|
+
}
|
|
6920
|
+
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
6921
|
+
return;
|
|
6922
|
+
}
|
|
6923
|
+
const outcome = await applyBlockedPushControls(targetDir, {
|
|
6924
|
+
stopOnHookRefusal: true,
|
|
6925
|
+
emit: (m) => m.level === "warning" ? printWarning(m.text) : printInfo(m.text)
|
|
6926
|
+
});
|
|
6927
|
+
const failedRemotes = outcome.urls.filter((u) => u.status === "failed");
|
|
6928
|
+
if (!hookInForce(outcome.hook) || failedRemotes.length > 0) {
|
|
6929
|
+
printWarning(
|
|
6930
|
+
outcome.stoppedAtHook ? "Nothing was changed. Resolve the refusal above and re-run `spell block-push`." : "Push is NOT fully blocked: the remotes named above can still be pushed to. Fix them and re-run `spell block-push`."
|
|
6931
|
+
);
|
|
6932
|
+
if (recorded !== "blocked") {
|
|
6933
|
+
printInfo(
|
|
6934
|
+
`The manifest still records push_policy: "${recorded}" \u2014 it is not changed to "blocked" until both controls are in force.`
|
|
6935
|
+
);
|
|
6936
|
+
}
|
|
6937
|
+
process.exit(1);
|
|
6938
|
+
return;
|
|
6939
|
+
}
|
|
6940
|
+
if (recorded !== "blocked") {
|
|
6941
|
+
await writeManifest(targetDir, { ...manifest, push_policy: "blocked" });
|
|
6942
|
+
printSuccess(`Push blocked. Recorded push_policy: "blocked" (was "${recorded}") in .arcane.json.`);
|
|
6943
|
+
printInfo("Commit that change so the repository's posture stays visible to everyone.");
|
|
6944
|
+
} else {
|
|
6945
|
+
printSuccess('Push blocked. The manifest already recorded push_policy: "blocked"; it is now enforced.');
|
|
6946
|
+
}
|
|
6947
|
+
}
|
|
6948
|
+
|
|
6373
6949
|
// src/commands/report.ts
|
|
6374
6950
|
import { access as access5 } from "fs/promises";
|
|
6375
|
-
import { join as
|
|
6951
|
+
import { join as join18 } from "path";
|
|
6376
6952
|
|
|
6377
6953
|
// src/modules/show-report/generate.ts
|
|
6378
|
-
import { mkdir as mkdir8, readdir as
|
|
6379
|
-
import { basename as basename3, dirname as dirname10, isAbsolute, join as
|
|
6954
|
+
import { mkdir as mkdir8, readdir as readdir11, readFile as readFile15, writeFile as writeFile11 } from "fs/promises";
|
|
6955
|
+
import { basename as basename3, dirname as dirname10, isAbsolute, join as join17, relative as relative3, resolve as resolve4 } from "path";
|
|
6380
6956
|
|
|
6381
6957
|
// src/modules/show-report/model.ts
|
|
6382
|
-
import { readFile as
|
|
6383
|
-
import { join as
|
|
6958
|
+
import { readFile as readFile14 } from "fs/promises";
|
|
6959
|
+
import { join as join16 } from "path";
|
|
6384
6960
|
|
|
6385
6961
|
// src/modules/show-report/plan-parser.ts
|
|
6386
6962
|
function parseFrontmatter(content) {
|
|
@@ -6650,7 +7226,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
|
|
|
6650
7226
|
// src/modules/show-report/model.ts
|
|
6651
7227
|
async function readOptional(path4) {
|
|
6652
7228
|
try {
|
|
6653
|
-
return await
|
|
7229
|
+
return await readFile14(path4, "utf8");
|
|
6654
7230
|
} catch {
|
|
6655
7231
|
return null;
|
|
6656
7232
|
}
|
|
@@ -6746,13 +7322,13 @@ function buildCorrections(ledgerRows, programName) {
|
|
|
6746
7322
|
};
|
|
6747
7323
|
}
|
|
6748
7324
|
async function buildShowReportModel(options) {
|
|
6749
|
-
const planAbsPath =
|
|
6750
|
-
const queueAbsPath =
|
|
7325
|
+
const planAbsPath = join16(options.rootDir, options.planRelPath);
|
|
7326
|
+
const queueAbsPath = join16(options.rootDir, options.queueRelPath);
|
|
6751
7327
|
const ledgerRelPath = "docs/verification-ledger.md";
|
|
6752
|
-
const ledgerAbsPath =
|
|
6753
|
-
const decisionsAbsPath =
|
|
7328
|
+
const ledgerAbsPath = join16(options.rootDir, ledgerRelPath);
|
|
7329
|
+
const decisionsAbsPath = join16(options.rootDir, "DECISIONS.md");
|
|
6754
7330
|
const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
|
|
6755
|
-
|
|
7331
|
+
readFile14(planAbsPath, "utf8"),
|
|
6756
7332
|
readOptional(queueAbsPath),
|
|
6757
7333
|
readOptional(ledgerAbsPath),
|
|
6758
7334
|
readOptional(decisionsAbsPath)
|
|
@@ -6921,7 +7497,7 @@ function toPosix(path4) {
|
|
|
6921
7497
|
async function programFromPlan(rootDir, slug, planRelPath) {
|
|
6922
7498
|
let planContent;
|
|
6923
7499
|
try {
|
|
6924
|
-
planContent = await
|
|
7500
|
+
planContent = await readFile15(join17(rootDir, planRelPath), "utf8");
|
|
6925
7501
|
} catch {
|
|
6926
7502
|
return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
|
|
6927
7503
|
}
|
|
@@ -6933,16 +7509,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
|
|
|
6933
7509
|
program: {
|
|
6934
7510
|
slug,
|
|
6935
7511
|
planRelPath,
|
|
6936
|
-
queueRelPath: toPosix(
|
|
7512
|
+
queueRelPath: toPosix(join17(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
|
|
6937
7513
|
ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
|
|
6938
7514
|
}
|
|
6939
7515
|
};
|
|
6940
7516
|
}
|
|
6941
7517
|
async function discoverPrograms(rootDir) {
|
|
6942
|
-
const plansDir =
|
|
7518
|
+
const plansDir = join17(rootDir, "docs", "plans");
|
|
6943
7519
|
let entries;
|
|
6944
7520
|
try {
|
|
6945
|
-
entries = (await
|
|
7521
|
+
entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
6946
7522
|
} catch (error) {
|
|
6947
7523
|
if (error.code === "ENOENT") return { programs: [], skipped: [] };
|
|
6948
7524
|
throw error;
|
|
@@ -6966,14 +7542,14 @@ function normalizeLineEndings2(content) {
|
|
|
6966
7542
|
}
|
|
6967
7543
|
async function readOptionalFile(path4) {
|
|
6968
7544
|
try {
|
|
6969
|
-
return await
|
|
7545
|
+
return await readFile15(path4, "utf8");
|
|
6970
7546
|
} catch (error) {
|
|
6971
7547
|
if (error.code === "ENOENT") return null;
|
|
6972
7548
|
throw error;
|
|
6973
7549
|
}
|
|
6974
7550
|
}
|
|
6975
7551
|
async function generateReport(rootDir, program2, templateHtml) {
|
|
6976
|
-
const planContent = await
|
|
7552
|
+
const planContent = await readFile15(join17(rootDir, program2.planRelPath), "utf8");
|
|
6977
7553
|
const frontmatter = parseFrontmatter(planContent);
|
|
6978
7554
|
const model = await buildShowReportModel({
|
|
6979
7555
|
rootDir,
|
|
@@ -6991,7 +7567,7 @@ async function generateReport(rootDir, program2, templateHtml) {
|
|
|
6991
7567
|
};
|
|
6992
7568
|
}
|
|
6993
7569
|
async function runReportGeneration(options) {
|
|
6994
|
-
const templateHtml = await
|
|
7570
|
+
const templateHtml = await readFile15(options.templatePath, "utf8");
|
|
6995
7571
|
const drifted = [];
|
|
6996
7572
|
const repaired = [];
|
|
6997
7573
|
const unwritten = [];
|
|
@@ -7006,12 +7582,12 @@ async function runReportGeneration(options) {
|
|
|
7006
7582
|
[`${outRel}/show-report.html`, generated.html]
|
|
7007
7583
|
];
|
|
7008
7584
|
for (const [relPath, expected] of outputs) {
|
|
7009
|
-
const actual = await readOptionalFile(
|
|
7585
|
+
const actual = await readOptionalFile(join17(options.rootDir, relPath));
|
|
7010
7586
|
if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
|
|
7011
7587
|
drifted.push(`${relPath} does not match a regeneration from its sources.`);
|
|
7012
7588
|
if (options.mode === "write") {
|
|
7013
7589
|
await mkdir8(outAbs, { recursive: true });
|
|
7014
|
-
await writeFile11(
|
|
7590
|
+
await writeFile11(join17(options.rootDir, relPath), expected, "utf8");
|
|
7015
7591
|
repaired.push(relPath);
|
|
7016
7592
|
}
|
|
7017
7593
|
}
|
|
@@ -7022,7 +7598,7 @@ async function runReportGeneration(options) {
|
|
|
7022
7598
|
|
|
7023
7599
|
// src/commands/report.ts
|
|
7024
7600
|
async function runReport(targetDir, options = {}, assetsDir) {
|
|
7025
|
-
const templatePath =
|
|
7601
|
+
const templatePath = join18(assetsDir, "report", "show-report.template.html");
|
|
7026
7602
|
try {
|
|
7027
7603
|
await access5(templatePath);
|
|
7028
7604
|
} catch {
|
|
@@ -7085,13 +7661,29 @@ async function runReport(targetDir, options = {}, assetsDir) {
|
|
|
7085
7661
|
import { realpathSync } from "fs";
|
|
7086
7662
|
var require3 = createRequire2(import.meta.url);
|
|
7087
7663
|
var pkg = require3("../package.json");
|
|
7088
|
-
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ??
|
|
7664
|
+
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join19(dirname11(fileURLToPath(import.meta.url)), "assets");
|
|
7089
7665
|
var program = new Command();
|
|
7090
7666
|
var USER_FLAG_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 spells shared by every repository on this machine, fanned out to ~/.agents/skills (Codex, Copilot) and ~/.claude/commands (Claude Code)";
|
|
7091
7667
|
var USER_TIER_AGENTS_DESCRIPTION = "Operate on the per-user tier at ~/.arcane \u2014 one agent roster shared by every repository on this machine, rendered to ~/.copilot/agents (VS Code agent modes)";
|
|
7092
7668
|
function targetDirFor(opts) {
|
|
7093
7669
|
return opts.user ? userTierRoot() : process.cwd();
|
|
7094
7670
|
}
|
|
7671
|
+
function withManifestFlags(command) {
|
|
7672
|
+
return command.option("--role <role>", "Answer the hub question: hub | consumer").option("--tracking-mode <mode>", "Answer the tracking question: internal | external (external needs --external-provider)").option("--external-provider <provider>", "With --tracking-mode external: ado | github | jira | other").option("--content-sensitivity <level>", "Answer the content question: standard | sensitive").option(
|
|
7673
|
+
"--push-policy <policy>",
|
|
7674
|
+
"Answer the push question: open | guarded | blocked. Can tighten a recorded policy, never loosen it (use `spell unblock-push`)"
|
|
7675
|
+
).option("--subject-root <path>", "Answer the subject question with a directory (docs profile)");
|
|
7676
|
+
}
|
|
7677
|
+
function manifestFlagsFrom(opts) {
|
|
7678
|
+
return {
|
|
7679
|
+
role: opts.role,
|
|
7680
|
+
trackingMode: opts.trackingMode,
|
|
7681
|
+
externalProvider: opts.externalProvider,
|
|
7682
|
+
contentSensitivity: opts.contentSensitivity,
|
|
7683
|
+
pushPolicy: opts.pushPolicy,
|
|
7684
|
+
subjectRoot: opts.subjectRoot
|
|
7685
|
+
};
|
|
7686
|
+
}
|
|
7095
7687
|
program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
|
|
7096
7688
|
if (program.args.length > 0) {
|
|
7097
7689
|
const [attempted] = program.args;
|
|
@@ -7111,14 +7703,17 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
|
|
|
7111
7703
|
await printWelcome(pkg.version, process.cwd());
|
|
7112
7704
|
program.outputHelp();
|
|
7113
7705
|
});
|
|
7114
|
-
|
|
7706
|
+
withManifestFlags(
|
|
7707
|
+
program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | docs | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").option("--user", USER_FLAG_DESCRIPTION)
|
|
7708
|
+
).action(
|
|
7115
7709
|
async (opts) => {
|
|
7116
7710
|
await runInit(
|
|
7117
7711
|
{
|
|
7118
7712
|
profile: opts.profile,
|
|
7119
7713
|
force: opts.force,
|
|
7120
7714
|
dryRun: opts.dryRun,
|
|
7121
|
-
user: opts.user
|
|
7715
|
+
user: opts.user,
|
|
7716
|
+
manifestFlags: manifestFlagsFrom(opts)
|
|
7122
7717
|
},
|
|
7123
7718
|
targetDirFor(opts),
|
|
7124
7719
|
ASSETS_DIR,
|
|
@@ -7137,12 +7732,14 @@ program.command("add <component>").description("Add a component to an existing A
|
|
|
7137
7732
|
);
|
|
7138
7733
|
}
|
|
7139
7734
|
);
|
|
7140
|
-
|
|
7141
|
-
"--
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
"--
|
|
7145
|
-
|
|
7735
|
+
withManifestFlags(
|
|
7736
|
+
program.command("update").description("Update installed components to the current package version").option("--dry-run", "Preview what would be updated without making changes").option(
|
|
7737
|
+
"--prune",
|
|
7738
|
+
"Also delete orphaned managed files (tracked before this update, no longer part of any current component). Hash-checked: an edited file is reported, never silently deleted"
|
|
7739
|
+
).option("--user", USER_FLAG_DESCRIPTION).option(
|
|
7740
|
+
"--default-scope <scope>",
|
|
7741
|
+
"With --user: remember repo | user as the answer `spell init` pre-selects for NEW repositories on this machine. Never rewrites an existing repository, and the question is still asked"
|
|
7742
|
+
)
|
|
7146
7743
|
).action(async (opts) => {
|
|
7147
7744
|
if (opts.defaultScope !== void 0) {
|
|
7148
7745
|
if (!opts.user) {
|
|
@@ -7163,6 +7760,7 @@ program.command("update").description("Update installed components to the curren
|
|
|
7163
7760
|
dryRun: opts.dryRun,
|
|
7164
7761
|
prune: opts.prune,
|
|
7165
7762
|
user: opts.user,
|
|
7763
|
+
manifestFlags: manifestFlagsFrom(opts),
|
|
7166
7764
|
...opts.defaultScope === void 0 ? {} : { defaultScope: opts.defaultScope }
|
|
7167
7765
|
},
|
|
7168
7766
|
targetDirFor(opts),
|
|
@@ -7176,6 +7774,9 @@ program.command("status").description("Show installed components and check for u
|
|
|
7176
7774
|
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").option("--user", USER_FLAG_DESCRIPTION).action(async (opts) => {
|
|
7177
7775
|
await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
|
|
7178
7776
|
});
|
|
7777
|
+
program.command("block-push").description("Enforce push_policy: blocked on this repository (pre-push hook + disabled push URLs); only ever tightens").action(async () => {
|
|
7778
|
+
await runBlockPush(process.cwd());
|
|
7779
|
+
});
|
|
7179
7780
|
program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
|
|
7180
7781
|
await runUnblockPush(process.cwd());
|
|
7181
7782
|
});
|