arcane-cli 1.5.1 → 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 +884 -349
- 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) {
|
|
@@ -2646,10 +2660,10 @@ async function runAgentsList(targetDir, scope = "repo") {
|
|
|
2646
2660
|
console.log();
|
|
2647
2661
|
}
|
|
2648
2662
|
|
|
2649
|
-
// src/modules/
|
|
2650
|
-
import {
|
|
2651
|
-
import {
|
|
2652
|
-
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";
|
|
2653
2667
|
|
|
2654
2668
|
// src/modules/git.ts
|
|
2655
2669
|
import { execFile } from "child_process";
|
|
@@ -2869,7 +2883,371 @@ async function ensureLocalPullRebase(cwd) {
|
|
|
2869
2883
|
}
|
|
2870
2884
|
}
|
|
2871
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
|
+
|
|
2872
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";
|
|
2873
3251
|
var DISABLED_PUSH_URL = "arcane-push-blocked-run-spell-unblock-push://this-repository";
|
|
2874
3252
|
var NO_PUSH_URL_MARKER = (remote) => `remote.${remote}.arcaneHadNoPushUrl`;
|
|
2875
3253
|
var ARCANE_HOOKS_DIR = ".arcane/hooks";
|
|
@@ -2960,10 +3338,10 @@ async function repositoryRoot(cwd) {
|
|
|
2960
3338
|
return cwd;
|
|
2961
3339
|
}
|
|
2962
3340
|
async function hookFilePath(cwd, hookName = "pre-push") {
|
|
2963
|
-
return
|
|
3341
|
+
return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR, hookName);
|
|
2964
3342
|
}
|
|
2965
3343
|
async function hooksPathValue(cwd) {
|
|
2966
|
-
return
|
|
3344
|
+
return join6(await repositoryRoot(cwd), ARCANE_HOOKS_DIR).replace(/\\/g, "/");
|
|
2967
3345
|
}
|
|
2968
3346
|
async function installHook(cwd, hookName, body) {
|
|
2969
3347
|
const root = await repositoryRoot(cwd);
|
|
@@ -2982,12 +3360,12 @@ async function installHook(cwd, hookName, body) {
|
|
|
2982
3360
|
return { status: "refused-default-hooks", hooks: displaced };
|
|
2983
3361
|
}
|
|
2984
3362
|
}
|
|
2985
|
-
const hookPath =
|
|
3363
|
+
const hookPath = join6(root, ARCANE_HOOKS_DIR, hookName);
|
|
2986
3364
|
if (existing !== void 0 && await hookFileMatches(cwd, hookName, body)) {
|
|
2987
3365
|
return { status: "already-ours" };
|
|
2988
3366
|
}
|
|
2989
|
-
await mkdir5(
|
|
2990
|
-
await
|
|
3367
|
+
await mkdir5(join6(root, ARCANE_HOOKS_DIR), { recursive: true });
|
|
3368
|
+
await writeFile8(hookPath, body, "utf-8");
|
|
2991
3369
|
await chmod(hookPath, 493);
|
|
2992
3370
|
await runGit(cwd, ["config", "--local", "core.hooksPath", await hooksPathValue(cwd)]);
|
|
2993
3371
|
return { status: "installed", path: hookPath };
|
|
@@ -3031,12 +3409,12 @@ async function defaultHooks(cwd) {
|
|
|
3031
3409
|
let hooksDir;
|
|
3032
3410
|
try {
|
|
3033
3411
|
const { stdout } = await runGit(cwd, ["rev-parse", "--git-common-dir"]);
|
|
3034
|
-
hooksDir =
|
|
3412
|
+
hooksDir = join6(resolve2(cwd, stdout.trim()), "hooks");
|
|
3035
3413
|
} catch {
|
|
3036
3414
|
return [];
|
|
3037
3415
|
}
|
|
3038
3416
|
try {
|
|
3039
|
-
return (await
|
|
3417
|
+
return (await readdir6(hooksDir)).filter((name) => !name.endsWith(".sample")).sort();
|
|
3040
3418
|
} catch {
|
|
3041
3419
|
return [];
|
|
3042
3420
|
}
|
|
@@ -3067,10 +3445,10 @@ async function removeHook(cwd, hookName) {
|
|
|
3067
3445
|
await rm4(await hookFilePath(cwd, hookName), { force: true });
|
|
3068
3446
|
} catch {
|
|
3069
3447
|
}
|
|
3070
|
-
const hooksDir =
|
|
3448
|
+
const hooksDir = join6(root, ARCANE_HOOKS_DIR);
|
|
3071
3449
|
let remaining = [];
|
|
3072
3450
|
try {
|
|
3073
|
-
remaining = await
|
|
3451
|
+
remaining = await readdir6(hooksDir);
|
|
3074
3452
|
} catch {
|
|
3075
3453
|
remaining = [];
|
|
3076
3454
|
}
|
|
@@ -3290,6 +3668,60 @@ function describeError(error) {
|
|
|
3290
3668
|
const message = error instanceof Error ? error.message : String(error);
|
|
3291
3669
|
return message.split("\n")[0].trim();
|
|
3292
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
|
+
}
|
|
3293
3725
|
|
|
3294
3726
|
// src/modules/secrets-scan.ts
|
|
3295
3727
|
var SECRETS_RULES = [
|
|
@@ -3399,6 +3831,13 @@ function buildProfileDescription(components, profile) {
|
|
|
3399
3831
|
}
|
|
3400
3832
|
async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
3401
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
|
+
}
|
|
3402
3841
|
await runInitUser(options, targetDir, assetsDir, packageVersion);
|
|
3403
3842
|
return;
|
|
3404
3843
|
}
|
|
@@ -3407,12 +3846,26 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3407
3846
|
`Invalid profile "${options.profile}". Valid options: ${VALID_PROFILES.join(", ")}`
|
|
3408
3847
|
);
|
|
3409
3848
|
}
|
|
3849
|
+
let flags = {};
|
|
3850
|
+
try {
|
|
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
|
+
}
|
|
3410
3860
|
try {
|
|
3411
3861
|
await readManifest(targetDir);
|
|
3412
3862
|
console.log(chalk2.hex("#a855f7")(`
|
|
3413
3863
|
\u2726 Arcane v${packageVersion}
|
|
3414
3864
|
`));
|
|
3415
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
|
+
}
|
|
3416
3869
|
return;
|
|
3417
3870
|
} catch (err) {
|
|
3418
3871
|
if (!(err instanceof ManifestNotFoundError)) throw err;
|
|
@@ -3444,7 +3897,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3444
3897
|
);
|
|
3445
3898
|
printInfo("Running spell init will add new files to your working tree.");
|
|
3446
3899
|
console.log();
|
|
3447
|
-
const continueAnyway = await
|
|
3900
|
+
const continueAnyway = await confirm2({
|
|
3448
3901
|
message: "Continue anyway?",
|
|
3449
3902
|
default: true
|
|
3450
3903
|
});
|
|
@@ -3465,7 +3918,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3465
3918
|
let profile = options.profile;
|
|
3466
3919
|
if (!profile) {
|
|
3467
3920
|
const profiles = listProfiles();
|
|
3468
|
-
profile = await
|
|
3921
|
+
profile = await select3({
|
|
3469
3922
|
message: "Select an installation profile:\n",
|
|
3470
3923
|
choices: profiles.map((p) => {
|
|
3471
3924
|
const profileComponents2 = getProfile(p.id);
|
|
@@ -3490,7 +3943,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3490
3943
|
}
|
|
3491
3944
|
if (storeVersion !== void 0) {
|
|
3492
3945
|
console.log();
|
|
3493
|
-
const useTier = await
|
|
3946
|
+
const useTier = await confirm2({
|
|
3494
3947
|
message: `This machine has an Arcane user tier (v${storeVersion}). Use it for this repository's spells instead of installing copies here?`,
|
|
3495
3948
|
default: preferred === "user"
|
|
3496
3949
|
});
|
|
@@ -3513,7 +3966,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3513
3966
|
console.log(` ${previewParts.join(" \xB7 ")}`);
|
|
3514
3967
|
console.log();
|
|
3515
3968
|
if (!options.dryRun && !options.profile) {
|
|
3516
|
-
const proceed = await
|
|
3969
|
+
const proceed = await confirm2({
|
|
3517
3970
|
message: "Proceed with installation?",
|
|
3518
3971
|
default: true
|
|
3519
3972
|
});
|
|
@@ -3528,7 +3981,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3528
3981
|
const installedFiles = [];
|
|
3529
3982
|
const fileHashes = {};
|
|
3530
3983
|
for (const file of component.files) {
|
|
3531
|
-
const srcPath =
|
|
3984
|
+
const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
3532
3985
|
if (options.dryRun) {
|
|
3533
3986
|
printDryRun(`Would copy: ${file}`);
|
|
3534
3987
|
} else {
|
|
@@ -3545,7 +3998,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3545
3998
|
fileCount++;
|
|
3546
3999
|
}
|
|
3547
4000
|
for (const dir of component.directories ?? []) {
|
|
3548
|
-
const srcDirPath =
|
|
4001
|
+
const srcDirPath = join7(assetsDir, dir);
|
|
3549
4002
|
if (options.dryRun) {
|
|
3550
4003
|
printDryRun(`Would copy directory: ${dir}/`);
|
|
3551
4004
|
} else {
|
|
@@ -3574,7 +4027,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3574
4027
|
if (options.dryRun) {
|
|
3575
4028
|
if (hasAgents) {
|
|
3576
4029
|
const agentFiles = await import("fs").then(
|
|
3577
|
-
(fsModule) => fsModule.promises.readdir(
|
|
4030
|
+
(fsModule) => fsModule.promises.readdir(join7(assetsDir, "agents"))
|
|
3578
4031
|
);
|
|
3579
4032
|
const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
|
|
3580
4033
|
printDryRun("Would run agent setup (interactive):");
|
|
@@ -3586,10 +4039,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3586
4039
|
printDryRun(`Would initialize with profile "${profile}" \u2014 ${fileCount} files`);
|
|
3587
4040
|
return;
|
|
3588
4041
|
}
|
|
3589
|
-
let role;
|
|
3590
|
-
if (!options.profile) {
|
|
4042
|
+
let role = flags.role;
|
|
4043
|
+
if (role === void 0 && !options.profile) {
|
|
3591
4044
|
console.log();
|
|
3592
|
-
const isHub = await
|
|
4045
|
+
const isHub = await confirm2({
|
|
3593
4046
|
message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
|
|
3594
4047
|
default: false
|
|
3595
4048
|
});
|
|
@@ -3597,36 +4050,20 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3597
4050
|
}
|
|
3598
4051
|
let tracking_mode;
|
|
3599
4052
|
let external_provider;
|
|
3600
|
-
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") {
|
|
3601
4057
|
tracking_mode = "internal";
|
|
3602
4058
|
external_provider = null;
|
|
3603
4059
|
} else if (!options.profile) {
|
|
3604
4060
|
console.log();
|
|
3605
|
-
tracking_mode = await
|
|
3606
|
-
message: "How will work be tracked in this repo?",
|
|
3607
|
-
choices: [
|
|
3608
|
-
{ value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
|
|
3609
|
-
{ value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
|
|
3610
|
-
]
|
|
3611
|
-
});
|
|
3612
|
-
if (tracking_mode === "external") {
|
|
3613
|
-
external_provider = await select2({
|
|
3614
|
-
message: "Which external tracker?",
|
|
3615
|
-
choices: [
|
|
3616
|
-
{ value: "ado", name: "Azure DevOps" },
|
|
3617
|
-
{ value: "github", name: "GitHub Issues" },
|
|
3618
|
-
{ value: "jira", name: "Jira" },
|
|
3619
|
-
{ value: "other", name: "Other" }
|
|
3620
|
-
]
|
|
3621
|
-
});
|
|
3622
|
-
} else {
|
|
3623
|
-
external_provider = null;
|
|
3624
|
-
}
|
|
4061
|
+
({ tracking_mode, external_provider } = await askTrackingQuestions(targetDir));
|
|
3625
4062
|
}
|
|
3626
|
-
let subject_root;
|
|
3627
|
-
if (profile === "docs" && !options.profile) {
|
|
4063
|
+
let subject_root = flags.subject_root;
|
|
4064
|
+
if (subject_root === void 0 && profile === "docs" && !options.profile) {
|
|
3628
4065
|
console.log();
|
|
3629
|
-
const shape = await
|
|
4066
|
+
const shape = await select3({
|
|
3630
4067
|
message: "What does this repository hold?",
|
|
3631
4068
|
choices: [
|
|
3632
4069
|
{
|
|
@@ -3640,7 +4077,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3640
4077
|
if (shape === "root") {
|
|
3641
4078
|
subject_root = ".";
|
|
3642
4079
|
} else if (shape === "subdir") {
|
|
3643
|
-
const answer = await
|
|
4080
|
+
const answer = await input3({
|
|
3644
4081
|
message: "Directory holding the subject's documents:",
|
|
3645
4082
|
default: "docs",
|
|
3646
4083
|
validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
|
|
@@ -3650,10 +4087,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3650
4087
|
subject_root = null;
|
|
3651
4088
|
}
|
|
3652
4089
|
}
|
|
3653
|
-
let content_sensitivity;
|
|
3654
|
-
if (!options.profile) {
|
|
4090
|
+
let content_sensitivity = flags.content_sensitivity;
|
|
4091
|
+
if (content_sensitivity === void 0 && !options.profile) {
|
|
3655
4092
|
console.log();
|
|
3656
|
-
content_sensitivity = await
|
|
4093
|
+
content_sensitivity = await select3({
|
|
3657
4094
|
message: "How should agents treat this repository's contents?",
|
|
3658
4095
|
choices: [
|
|
3659
4096
|
{ value: "standard", name: "Standard \u2014 agents may quote contents in journals and decisions" },
|
|
@@ -3665,10 +4102,10 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3665
4102
|
default: "standard"
|
|
3666
4103
|
});
|
|
3667
4104
|
}
|
|
3668
|
-
let push_policy;
|
|
3669
|
-
if (!options.profile) {
|
|
4105
|
+
let push_policy = flags.push_policy;
|
|
4106
|
+
if (push_policy === void 0 && !options.profile) {
|
|
3670
4107
|
console.log();
|
|
3671
|
-
push_policy = await
|
|
4108
|
+
push_policy = await select3({
|
|
3672
4109
|
message: "Should this repository be allowed to push to a remote?",
|
|
3673
4110
|
choices: [
|
|
3674
4111
|
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
@@ -3695,43 +4132,23 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3695
4132
|
};
|
|
3696
4133
|
await writeManifest(targetDir, manifest);
|
|
3697
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
|
+
}
|
|
3698
4140
|
if (push_policy === "blocked" || push_policy === "guarded") {
|
|
3699
4141
|
try {
|
|
3700
4142
|
if (push_policy === "blocked") {
|
|
3701
|
-
const
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
);
|
|
3706
|
-
} else if (hook.status === "refused-default-hooks") {
|
|
3707
|
-
printWarning(
|
|
3708
|
-
`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.`
|
|
3709
|
-
);
|
|
3710
|
-
} else if (hook.status === "refused-foreign-hooks-path") {
|
|
3711
|
-
printWarning(
|
|
3712
|
-
`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.`
|
|
3713
|
-
);
|
|
3714
|
-
} else {
|
|
3715
|
-
printInfo("Installed a pre-push hook that blocks pushes from this repository.");
|
|
3716
|
-
}
|
|
3717
|
-
const urls = await disablePushUrls(targetDir);
|
|
3718
|
-
const unprotected = urls.filter((u) => u.status === "failed");
|
|
3719
|
-
if (unprotected.length > 0) {
|
|
3720
|
-
printWarning(
|
|
3721
|
-
`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.`
|
|
3722
|
-
);
|
|
3723
|
-
}
|
|
3724
|
-
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")) {
|
|
3725
4147
|
printWarning(
|
|
3726
|
-
|
|
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.'
|
|
3727
4149
|
);
|
|
3728
|
-
|
|
3729
|
-
const covered = urls.filter((u) => u.status !== "failed").map((u) => u.remote);
|
|
3730
|
-
if (covered.length > 0) {
|
|
3731
|
-
printInfo(`Disabled the push URL for: ${covered.join(", ")} (fetch still works).`);
|
|
3732
|
-
}
|
|
4150
|
+
process.exitCode = 1;
|
|
3733
4151
|
}
|
|
3734
|
-
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
3735
4152
|
} else {
|
|
3736
4153
|
printInfo(
|
|
3737
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."
|
|
@@ -3777,7 +4194,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
3777
4194
|
let agentsConfigured = false;
|
|
3778
4195
|
if (hasAgents && !options.profile) {
|
|
3779
4196
|
console.log();
|
|
3780
|
-
const setupAgents = await
|
|
4197
|
+
const setupAgents = await confirm2({
|
|
3781
4198
|
message: "\u{1F916} Set up your agent team? (names, roles, AI tool configs)",
|
|
3782
4199
|
default: true
|
|
3783
4200
|
});
|
|
@@ -3823,6 +4240,11 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3823
4240
|
'"--profile" does not apply to "--user": the user tier always installs every spell and nothing else.'
|
|
3824
4241
|
);
|
|
3825
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
|
+
}
|
|
3826
4248
|
try {
|
|
3827
4249
|
await readManifest(storeRoot);
|
|
3828
4250
|
console.log(chalk2.hex("#a855f7")(`
|
|
@@ -3856,7 +4278,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3856
4278
|
for (const id of spellIds) {
|
|
3857
4279
|
for (const client of ["codex", "claude"]) {
|
|
3858
4280
|
const relativePath = USER_FANOUT_PATHS[client](id);
|
|
3859
|
-
if (await fileExists(
|
|
4281
|
+
if (await fileExists(join7(homeDir, relativePath))) {
|
|
3860
4282
|
printDryRun(`Would leave alone (exists, not written by Arcane): ~/${relativePath}`);
|
|
3861
4283
|
} else {
|
|
3862
4284
|
printDryRun(`Would write: ~/${relativePath}`);
|
|
@@ -3874,7 +4296,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3874
4296
|
const installedFiles = [];
|
|
3875
4297
|
const fileHashes = {};
|
|
3876
4298
|
for (const file of component.files) {
|
|
3877
|
-
const srcPath =
|
|
4299
|
+
const srcPath = join7(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
3878
4300
|
fileHashes[file] = await copyFile(srcPath, storeRoot, file, { force: options.force });
|
|
3879
4301
|
installedFiles.push(file);
|
|
3880
4302
|
fileCount++;
|
|
@@ -3920,7 +4342,7 @@ async function runInitUser(options, storeRoot, assetsDir, packageVersion) {
|
|
|
3920
4342
|
}
|
|
3921
4343
|
|
|
3922
4344
|
// src/commands/add.ts
|
|
3923
|
-
import { join as
|
|
4345
|
+
import { join as join8 } from "path";
|
|
3924
4346
|
async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
3925
4347
|
let manifest;
|
|
3926
4348
|
try {
|
|
@@ -3956,7 +4378,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
3956
4378
|
const installedFiles = [];
|
|
3957
4379
|
const fileHashes = {};
|
|
3958
4380
|
for (const file of component.files) {
|
|
3959
|
-
const srcPath =
|
|
4381
|
+
const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
3960
4382
|
if (options.dryRun) {
|
|
3961
4383
|
console.log(` [dry-run] Would copy: ${file}`);
|
|
3962
4384
|
} else {
|
|
@@ -3966,7 +4388,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
3966
4388
|
fileCount++;
|
|
3967
4389
|
}
|
|
3968
4390
|
for (const dir of component.directories ?? []) {
|
|
3969
|
-
const srcDirPath =
|
|
4391
|
+
const srcDirPath = join8(assetsDir, dir);
|
|
3970
4392
|
if (options.dryRun) {
|
|
3971
4393
|
console.log(` [dry-run] Would copy directory: ${dir}/`);
|
|
3972
4394
|
} else {
|
|
@@ -4003,194 +4425,6 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
4003
4425
|
import { dirname as dirname5, join as join9 } from "path";
|
|
4004
4426
|
import { readFile as readFile7, writeFile as writeFile9 } from "fs/promises";
|
|
4005
4427
|
|
|
4006
|
-
// src/modules/hub.ts
|
|
4007
|
-
import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
|
|
4008
|
-
import { readdir as readdir6, writeFile as writeFile8 } from "fs/promises";
|
|
4009
|
-
import { join as join8 } from "path";
|
|
4010
|
-
var MANIFEST_RETROFITS = [
|
|
4011
|
-
{
|
|
4012
|
-
field: "role",
|
|
4013
|
-
needsRetrofit: (m) => m.role === void 0,
|
|
4014
|
-
ask: async () => {
|
|
4015
|
-
const isHub = await confirm2({
|
|
4016
|
-
message: "Will this repo manage other ventures as a hub? (idea books, spell-manifest promotion, venture registry)",
|
|
4017
|
-
default: false
|
|
4018
|
-
});
|
|
4019
|
-
return { role: isHub ? "hub" : "consumer" };
|
|
4020
|
-
}
|
|
4021
|
-
},
|
|
4022
|
-
{
|
|
4023
|
-
field: "tracking_mode",
|
|
4024
|
-
needsRetrofit: (m) => m.tracking_mode === void 0,
|
|
4025
|
-
// Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
|
|
4026
|
-
// profiles get a silent default, full/lite get asked.
|
|
4027
|
-
ask: async (manifest) => {
|
|
4028
|
-
if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
|
|
4029
|
-
return { tracking_mode: "internal", external_provider: null };
|
|
4030
|
-
}
|
|
4031
|
-
const tracking_mode = await select3({
|
|
4032
|
-
message: "How will work be tracked in this repo?",
|
|
4033
|
-
choices: [
|
|
4034
|
-
{ value: "internal", name: "Track work in this repo (TODO.md / PRDs)" },
|
|
4035
|
-
{ value: "external", name: "Track work in an external tracker (Azure DevOps / GitHub / Jira / other)" }
|
|
4036
|
-
]
|
|
4037
|
-
});
|
|
4038
|
-
if (tracking_mode === "external") {
|
|
4039
|
-
const external_provider = await select3({
|
|
4040
|
-
message: "Which external tracker?",
|
|
4041
|
-
choices: [
|
|
4042
|
-
{ value: "ado", name: "Azure DevOps" },
|
|
4043
|
-
{ value: "github", name: "GitHub Issues" },
|
|
4044
|
-
{ value: "jira", name: "Jira" },
|
|
4045
|
-
{ value: "other", name: "Other" }
|
|
4046
|
-
]
|
|
4047
|
-
});
|
|
4048
|
-
return { tracking_mode, external_provider };
|
|
4049
|
-
}
|
|
4050
|
-
return { tracking_mode, external_provider: null };
|
|
4051
|
-
}
|
|
4052
|
-
},
|
|
4053
|
-
{
|
|
4054
|
-
field: "content_sensitivity",
|
|
4055
|
-
needsRetrofit: (m) => m.content_sensitivity === void 0,
|
|
4056
|
-
// EF-12. Asked for every profile -- a code repo can hold sensitive records
|
|
4057
|
-
// too -- and defaults to "standard", so an operator who just presses enter
|
|
4058
|
-
// keeps today's behaviour exactly.
|
|
4059
|
-
ask: async () => {
|
|
4060
|
-
const content_sensitivity = await select3({
|
|
4061
|
-
message: "How should agents treat this repository's contents?",
|
|
4062
|
-
choices: [
|
|
4063
|
-
{
|
|
4064
|
-
value: "standard",
|
|
4065
|
-
name: "Standard \u2014 agents may quote contents in journals and decisions"
|
|
4066
|
-
},
|
|
4067
|
-
{
|
|
4068
|
-
value: "sensitive",
|
|
4069
|
-
name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
|
|
4070
|
-
}
|
|
4071
|
-
],
|
|
4072
|
-
default: "standard"
|
|
4073
|
-
});
|
|
4074
|
-
return { content_sensitivity };
|
|
4075
|
-
}
|
|
4076
|
-
},
|
|
4077
|
-
{
|
|
4078
|
-
field: "subject_root",
|
|
4079
|
-
// EF-07. Only the docs profile is asked: other profiles describe code or a
|
|
4080
|
-
// venture portfolio, where "what is this repo about" is already answered.
|
|
4081
|
-
// A docs install that legitimately holds several subjects answers
|
|
4082
|
-
// "portfolio" and stays unset -- so this retrofit deliberately does NOT
|
|
4083
|
-
// re-ask on every update. It is gated on the field being absent AND the
|
|
4084
|
-
// profile being docs, and once answered (either way) it never fires again,
|
|
4085
|
-
// because "portfolio" writes an explicit empty marker.
|
|
4086
|
-
needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
|
|
4087
|
-
ask: async () => {
|
|
4088
|
-
const shape = await select3({
|
|
4089
|
-
message: "What does this repository hold?",
|
|
4090
|
-
choices: [
|
|
4091
|
-
{
|
|
4092
|
-
value: "root",
|
|
4093
|
-
name: "One subject, at the repository root (documents sit alongside Arcane's files)"
|
|
4094
|
-
},
|
|
4095
|
-
{ value: "subdir", name: "One subject, in its own directory" },
|
|
4096
|
-
{ value: "portfolio", name: "Several subjects or ventures" }
|
|
4097
|
-
]
|
|
4098
|
-
});
|
|
4099
|
-
if (shape === "root") return { subject_root: "." };
|
|
4100
|
-
if (shape === "subdir") {
|
|
4101
|
-
const answer = await input3({
|
|
4102
|
-
message: "Directory holding the subject's documents:",
|
|
4103
|
-
default: "docs",
|
|
4104
|
-
validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
|
|
4105
|
-
});
|
|
4106
|
-
return { subject_root: answer.trim() || "docs" };
|
|
4107
|
-
}
|
|
4108
|
-
return { subject_root: null };
|
|
4109
|
-
}
|
|
4110
|
-
},
|
|
4111
|
-
{
|
|
4112
|
-
field: "push_policy",
|
|
4113
|
-
needsRetrofit: (m) => m.push_policy === void 0,
|
|
4114
|
-
// EF-09. Defaults to "open", so an operator who just presses enter keeps
|
|
4115
|
-
// today's behaviour exactly. NOTE: this only records the choice -- unlike
|
|
4116
|
-
// init, the retrofit does not install the hook or disable the push URL,
|
|
4117
|
-
// because an update should not silently start blocking pushes in a repo
|
|
4118
|
-
// someone is mid-workflow in. doctor reports the gap instead.
|
|
4119
|
-
ask: async () => {
|
|
4120
|
-
const push_policy = await select3({
|
|
4121
|
-
message: "Should this repository be allowed to push to a remote?",
|
|
4122
|
-
choices: [
|
|
4123
|
-
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
4124
|
-
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
4125
|
-
{ value: "blocked", name: "No \u2014 block pushes" }
|
|
4126
|
-
],
|
|
4127
|
-
default: "open"
|
|
4128
|
-
});
|
|
4129
|
-
return { push_policy };
|
|
4130
|
-
}
|
|
4131
|
-
}
|
|
4132
|
-
];
|
|
4133
|
-
async function runManifestRetrofits(manifest) {
|
|
4134
|
-
const applicable = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
|
|
4135
|
-
if (applicable.length === 0) return {};
|
|
4136
|
-
console.log();
|
|
4137
|
-
console.log(
|
|
4138
|
-
`This install predates ${applicable.length} manifest field${applicable.length === 1 ? "" : "s"} -- a couple of quick questions:`
|
|
4139
|
-
);
|
|
4140
|
-
let patch = {};
|
|
4141
|
-
for (const retrofit of applicable) {
|
|
4142
|
-
const answer = await retrofit.ask({ ...manifest, ...patch });
|
|
4143
|
-
patch = { ...patch, ...answer };
|
|
4144
|
-
}
|
|
4145
|
-
return patch;
|
|
4146
|
-
}
|
|
4147
|
-
async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
4148
|
-
const registryPath = join8(targetDir, businessRoot, "registry.json");
|
|
4149
|
-
if (await fileExists(registryPath)) return;
|
|
4150
|
-
const businessRootPath = join8(targetDir, businessRoot);
|
|
4151
|
-
let ventureDirs;
|
|
4152
|
-
try {
|
|
4153
|
-
const entries = await readdir6(businessRootPath, { withFileTypes: true });
|
|
4154
|
-
ventureDirs = entries.filter((e) => e.isDirectory() && e.name !== "_template").map((e) => e.name);
|
|
4155
|
-
} catch {
|
|
4156
|
-
return;
|
|
4157
|
-
}
|
|
4158
|
-
if (ventureDirs.length === 0) return;
|
|
4159
|
-
console.log();
|
|
4160
|
-
const scaffold = await confirm2({
|
|
4161
|
-
message: `Found ${ventureDirs.length} folder${ventureDirs.length === 1 ? "" : "s"} under ${businessRoot}/ -- scaffold ${businessRoot}/registry.json from them?`,
|
|
4162
|
-
default: true
|
|
4163
|
-
});
|
|
4164
|
-
if (!scaffold) return;
|
|
4165
|
-
const ventures = {};
|
|
4166
|
-
for (const slug of ventureDirs) {
|
|
4167
|
-
const include = await confirm2({ message: ` Include "${slug}"?`, default: true });
|
|
4168
|
-
if (!include) continue;
|
|
4169
|
-
ventures[slug] = {
|
|
4170
|
-
name: slug,
|
|
4171
|
-
aliases: [],
|
|
4172
|
-
status: "active",
|
|
4173
|
-
visibility: "private",
|
|
4174
|
-
ownership: "llc",
|
|
4175
|
-
tracking: "none",
|
|
4176
|
-
repos: []
|
|
4177
|
-
};
|
|
4178
|
-
}
|
|
4179
|
-
if (Object.keys(ventures).length === 0) {
|
|
4180
|
-
console.log(" No ventures selected -- registry not created.");
|
|
4181
|
-
return;
|
|
4182
|
-
}
|
|
4183
|
-
const registry = {
|
|
4184
|
-
_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.",
|
|
4185
|
-
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
4186
|
-
ventures
|
|
4187
|
-
};
|
|
4188
|
-
await writeFile8(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
|
|
4189
|
-
console.log(
|
|
4190
|
-
` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
|
|
4191
|
-
);
|
|
4192
|
-
}
|
|
4193
|
-
|
|
4194
4428
|
// src/modules/merge3.ts
|
|
4195
4429
|
var CONFLICT_START = "<<<<<<< yours (edited)";
|
|
4196
4430
|
var CONFLICT_MID = "=======";
|
|
@@ -4354,6 +4588,14 @@ async function missingTierWarning(scope, spellScope, homeDir) {
|
|
|
4354
4588
|
];
|
|
4355
4589
|
}
|
|
4356
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
|
+
}
|
|
4357
4599
|
function defaultScopePatch(options, manifest) {
|
|
4358
4600
|
if (options.defaultScope === void 0) return void 0;
|
|
4359
4601
|
if (manifest.default_spell_scope === options.defaultScope) return void 0;
|
|
@@ -4496,6 +4738,27 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4496
4738
|
return;
|
|
4497
4739
|
}
|
|
4498
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 };
|
|
4499
4762
|
const spellScope = effectiveSpellScope(manifest);
|
|
4500
4763
|
const readopted = findReadoptedSpellComponents(manifest.components, scope, spellScope);
|
|
4501
4764
|
const homeDir = dirname5(targetDir);
|
|
@@ -4536,6 +4799,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4536
4799
|
const unmanaged = findUnmanagedTrackedFiles(manifest.components, scope, spellScope);
|
|
4537
4800
|
if (missing.length === 0 && unmanaged.length === 0) {
|
|
4538
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
|
+
}
|
|
4539
4806
|
if (scope === "user") {
|
|
4540
4807
|
const fanout = await syncUserTierFanout({
|
|
4541
4808
|
ownedClients: SPELL_FANOUT_CLIENTS,
|
|
@@ -4568,6 +4835,10 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
4568
4835
|
}
|
|
4569
4836
|
if (manifest.components.length === 0) {
|
|
4570
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
|
+
}
|
|
4571
4842
|
return;
|
|
4572
4843
|
}
|
|
4573
4844
|
let fileCount = 0;
|
|
@@ -4789,7 +5060,8 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4789
5060
|
`
|
|
4790
5061
|
[dry-run] Would update ${fileCount} files.`
|
|
4791
5062
|
);
|
|
4792
|
-
|
|
5063
|
+
if (Object.keys(flagPatch).length > 0) reportFlagPatch(flagPatch, manifest, answered, true);
|
|
5064
|
+
const applicableRetrofits = scope === "repo" ? MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered)) : [];
|
|
4793
5065
|
if (applicableRetrofits.length > 0) {
|
|
4794
5066
|
console.log(
|
|
4795
5067
|
`[dry-run] Would ask ${applicableRetrofits.length} manifest retrofit question${applicableRetrofits.length === 1 ? "" : "s"}: ${applicableRetrofits.map((r) => r.field).join(", ")}.`
|
|
@@ -4798,19 +5070,21 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4798
5070
|
return;
|
|
4799
5071
|
}
|
|
4800
5072
|
const interactive = Boolean(process.stdin.isTTY) && scope === "repo";
|
|
4801
|
-
const retrofitPatch = interactive ? await runManifestRetrofits(
|
|
5073
|
+
const retrofitPatch = interactive ? await runManifestRetrofits(answered, targetDir) : {};
|
|
4802
5074
|
if (!interactive && scope === "repo") {
|
|
4803
|
-
const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(
|
|
5075
|
+
const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(answered));
|
|
4804
5076
|
if (pending.length > 0) {
|
|
4805
5077
|
console.log(
|
|
4806
|
-
` ! 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:`
|
|
4807
5079
|
);
|
|
5080
|
+
for (const line of manifestFlagLine(answered) ?? []) console.log(` ${line}`);
|
|
4808
5081
|
}
|
|
4809
5082
|
}
|
|
4810
5083
|
const updated = {
|
|
4811
5084
|
...manifest,
|
|
4812
5085
|
version: packageVersion,
|
|
4813
5086
|
components: updatedComponents,
|
|
5087
|
+
...flagPatch,
|
|
4814
5088
|
...retrofitPatch,
|
|
4815
5089
|
...fanoutRecord !== void 0 ? { fanout: fanoutRecord } : {},
|
|
4816
5090
|
...defaultScopePatch(options, manifest)
|
|
@@ -4820,6 +5094,11 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4820
5094
|
`
|
|
4821
5095
|
\u2713 Updated ${fileCount} files.`
|
|
4822
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
|
+
}
|
|
4823
5102
|
if (conflictedFiles.length > 0) {
|
|
4824
5103
|
console.log(
|
|
4825
5104
|
`
|
|
@@ -4829,7 +5108,13 @@ ${verb} ${orphansToReport.length} orphaned file(s) (tracked before this update,
|
|
|
4829
5108
|
);
|
|
4830
5109
|
}
|
|
4831
5110
|
if (scope === "repo" && manifest.role !== "hub" && updated.role === "hub") {
|
|
4832
|
-
|
|
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
|
+
}
|
|
4833
5118
|
}
|
|
4834
5119
|
}
|
|
4835
5120
|
|
|
@@ -5240,8 +5525,8 @@ async function uninstallUserTier(options, storeRoot, manifest) {
|
|
|
5240
5525
|
}
|
|
5241
5526
|
|
|
5242
5527
|
// src/commands/doctor.ts
|
|
5243
|
-
import { access as access4, readdir as
|
|
5244
|
-
import { join as
|
|
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";
|
|
5245
5530
|
|
|
5246
5531
|
// src/config/incidents.ts
|
|
5247
5532
|
var INCIDENT_QUEUE = [
|
|
@@ -5576,6 +5861,133 @@ function parseAdoRemote(url) {
|
|
|
5576
5861
|
return null;
|
|
5577
5862
|
}
|
|
5578
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
|
+
|
|
5579
5991
|
// src/commands/doctor.ts
|
|
5580
5992
|
function isRecord3(value) {
|
|
5581
5993
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -5596,14 +6008,14 @@ async function checkNodeVersion() {
|
|
|
5596
6008
|
async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
|
|
5597
6009
|
const name = `VS Code extension: ${label}`;
|
|
5598
6010
|
const flavours = [
|
|
5599
|
-
{ dir:
|
|
5600
|
-
{ dir:
|
|
6011
|
+
{ dir: join14(homeDir, ".vscode", "extensions"), bin: "code" },
|
|
6012
|
+
{ dir: join14(homeDir, ".vscode-insiders", "extensions"), bin: "code-insiders" }
|
|
5601
6013
|
];
|
|
5602
6014
|
let installBin = null;
|
|
5603
6015
|
for (const flavour of flavours) {
|
|
5604
6016
|
let entries;
|
|
5605
6017
|
try {
|
|
5606
|
-
entries = await
|
|
6018
|
+
entries = await readdir9(flavour.dir);
|
|
5607
6019
|
} catch {
|
|
5608
6020
|
continue;
|
|
5609
6021
|
}
|
|
@@ -5625,13 +6037,13 @@ async function checkVSCodeExtension(id, label, homeDir = resolveHomeDir()) {
|
|
|
5625
6037
|
}
|
|
5626
6038
|
async function checkArcaneManifest(targetDir) {
|
|
5627
6039
|
const name = "Arcane manifest (.arcane.json)";
|
|
5628
|
-
const manifestPath2 =
|
|
6040
|
+
const manifestPath2 = join14(targetDir, ".arcane.json");
|
|
5629
6041
|
try {
|
|
5630
6042
|
await access4(manifestPath2);
|
|
5631
6043
|
} catch {
|
|
5632
|
-
const selfHostedManifestPath =
|
|
6044
|
+
const selfHostedManifestPath = join14(targetDir, "src", "assets", ".arcane.json");
|
|
5633
6045
|
try {
|
|
5634
|
-
const raw = await
|
|
6046
|
+
const raw = await readFile11(selfHostedManifestPath, "utf8");
|
|
5635
6047
|
const manifest = JSON.parse(raw);
|
|
5636
6048
|
if (isRecord3(manifest) && manifest["selfHosted"] === true && manifest["tracking_mode"] === "internal") {
|
|
5637
6049
|
return {
|
|
@@ -5650,7 +6062,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
5650
6062
|
};
|
|
5651
6063
|
}
|
|
5652
6064
|
try {
|
|
5653
|
-
const raw = await
|
|
6065
|
+
const raw = await readFile11(manifestPath2, "utf8");
|
|
5654
6066
|
const manifest = JSON.parse(raw);
|
|
5655
6067
|
if (!isRecord3(manifest) || typeof manifest["version"] !== "string" || !manifest["version"] || !Array.isArray(manifest["components"])) {
|
|
5656
6068
|
return {
|
|
@@ -5735,7 +6147,7 @@ async function checkSessionContinuity(targetDir) {
|
|
|
5735
6147
|
const results = [];
|
|
5736
6148
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
5737
6149
|
const name = `Session file: ${file}`;
|
|
5738
|
-
const filePath =
|
|
6150
|
+
const filePath = join14(targetDir, file);
|
|
5739
6151
|
try {
|
|
5740
6152
|
await access4(filePath);
|
|
5741
6153
|
results.push({ name, passed: true, message: "present" });
|
|
@@ -5753,11 +6165,11 @@ async function checkSessionContinuity(targetDir) {
|
|
|
5753
6165
|
async function fixSessionContinuity(targetDir, assetsDir) {
|
|
5754
6166
|
const created = [];
|
|
5755
6167
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
5756
|
-
const destPath =
|
|
6168
|
+
const destPath = join14(targetDir, file);
|
|
5757
6169
|
try {
|
|
5758
6170
|
await access4(destPath);
|
|
5759
6171
|
} catch {
|
|
5760
|
-
const srcPath =
|
|
6172
|
+
const srcPath = join14(assetsDir, file);
|
|
5761
6173
|
await mkdir7(dirname9(destPath), { recursive: true });
|
|
5762
6174
|
await fsCopyFile(srcPath, destPath);
|
|
5763
6175
|
created.push(file);
|
|
@@ -5877,15 +6289,48 @@ async function checkPushPolicy(targetDir) {
|
|
|
5877
6289
|
name,
|
|
5878
6290
|
passed: false,
|
|
5879
6291
|
blocking: false,
|
|
5880
|
-
|
|
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\`.`
|
|
5881
6326
|
};
|
|
5882
6327
|
}
|
|
5883
6328
|
async function checkMcpConfig(targetDir) {
|
|
5884
6329
|
const name = "MCP config (per-server timeout, I12)";
|
|
5885
|
-
const filePath =
|
|
6330
|
+
const filePath = join14(targetDir, ".mcp.json");
|
|
5886
6331
|
let raw;
|
|
5887
6332
|
try {
|
|
5888
|
-
raw = await
|
|
6333
|
+
raw = await readFile11(filePath, "utf8");
|
|
5889
6334
|
} catch {
|
|
5890
6335
|
return { name, passed: true, blocking: false, message: "no .mcp.json \u2014 nothing to check" };
|
|
5891
6336
|
}
|
|
@@ -5938,10 +6383,10 @@ function isDelegationsFile(value) {
|
|
|
5938
6383
|
}
|
|
5939
6384
|
async function checkDelegations(targetDir) {
|
|
5940
6385
|
const name = "Standing delegations (T13)";
|
|
5941
|
-
const filePath =
|
|
6386
|
+
const filePath = join14(targetDir, ".arcane", "delegations.json");
|
|
5942
6387
|
let raw;
|
|
5943
6388
|
try {
|
|
5944
|
-
raw = await
|
|
6389
|
+
raw = await readFile11(filePath, "utf8");
|
|
5945
6390
|
} catch {
|
|
5946
6391
|
return { name, passed: true, blocking: false, message: "no delegations recorded" };
|
|
5947
6392
|
}
|
|
@@ -6051,7 +6496,7 @@ async function checkSpellScope(targetDir, homeDir = resolveHomeDir()) {
|
|
|
6051
6496
|
let probed = false;
|
|
6052
6497
|
const probe = "spell-status.md";
|
|
6053
6498
|
try {
|
|
6054
|
-
const entries = await
|
|
6499
|
+
const entries = await readdir9(join14(storeRoot, USER_TIER_SPELLS_DIR));
|
|
6055
6500
|
const spells = entries.filter((e) => e.startsWith("spell-") && e.endsWith(".md"));
|
|
6056
6501
|
spellCount = spells.length;
|
|
6057
6502
|
probed = spells.includes(probe);
|
|
@@ -6114,16 +6559,18 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
6114
6559
|
checkDelegations(targetDir),
|
|
6115
6560
|
checkMcpConfig(targetDir),
|
|
6116
6561
|
checkUserTier(resolveHomeDir(), options.packageVersion),
|
|
6117
|
-
checkSpellScope(targetDir)
|
|
6562
|
+
checkSpellScope(targetDir),
|
|
6563
|
+
checkGovernancePlaceholders(targetDir)
|
|
6118
6564
|
]);
|
|
6119
6565
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
6120
6566
|
results.push(...sessionResults);
|
|
6121
6567
|
let allPassed = true;
|
|
6122
6568
|
for (const result of results) {
|
|
6123
|
-
const
|
|
6124
|
-
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";
|
|
6125
6572
|
console.log(` ${icon} [${label}] ${result.name}`);
|
|
6126
|
-
if (!result.passed) {
|
|
6573
|
+
if (!result.passed || skipped) {
|
|
6127
6574
|
console.log(` ${result.message}`);
|
|
6128
6575
|
}
|
|
6129
6576
|
if (!result.passed && result.blocking !== false) {
|
|
@@ -6157,16 +6604,16 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
6157
6604
|
}
|
|
6158
6605
|
|
|
6159
6606
|
// src/commands/ward.ts
|
|
6160
|
-
import { readFile as
|
|
6607
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
6161
6608
|
|
|
6162
6609
|
// src/modules/ward.ts
|
|
6163
|
-
import { readdir as
|
|
6164
|
-
import { join as
|
|
6610
|
+
import { readdir as readdir10, readFile as readFile12 } from "fs/promises";
|
|
6611
|
+
import { join as join15, relative as relative2 } from "path";
|
|
6165
6612
|
async function scanFileAllRules(absolutePath, displayPath, rules) {
|
|
6166
6613
|
const findings = [];
|
|
6167
6614
|
let content;
|
|
6168
6615
|
try {
|
|
6169
|
-
content = await
|
|
6616
|
+
content = await readFile12(absolutePath, "utf8");
|
|
6170
6617
|
} catch {
|
|
6171
6618
|
return findings;
|
|
6172
6619
|
}
|
|
@@ -6219,12 +6666,12 @@ async function scanFilenames(root, rules, repoRoot = root) {
|
|
|
6219
6666
|
const findings = [];
|
|
6220
6667
|
let entries;
|
|
6221
6668
|
try {
|
|
6222
|
-
entries = await
|
|
6669
|
+
entries = await readdir10(root, { withFileTypes: true });
|
|
6223
6670
|
} catch {
|
|
6224
6671
|
return findings;
|
|
6225
6672
|
}
|
|
6226
6673
|
for (const entry of entries) {
|
|
6227
|
-
const absolutePath =
|
|
6674
|
+
const absolutePath = join15(root, entry.name);
|
|
6228
6675
|
if (entry.isDirectory()) {
|
|
6229
6676
|
if (SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
6230
6677
|
findings.push(...await scanFilenames(absolutePath, rules, repoRoot));
|
|
@@ -6261,7 +6708,7 @@ async function runWard(repoRoot, options = {}) {
|
|
|
6261
6708
|
const findings2 = await scanFileAllRules(absolutePath, displayPath, allRules);
|
|
6262
6709
|
if (findings2.length > 0) {
|
|
6263
6710
|
try {
|
|
6264
|
-
fileLines.set(displayPath, (await
|
|
6711
|
+
fileLines.set(displayPath, (await readFile12(absolutePath, "utf8")).split("\n"));
|
|
6265
6712
|
} catch {
|
|
6266
6713
|
}
|
|
6267
6714
|
}
|
|
@@ -6289,7 +6736,7 @@ function parseTermsFileContent(raw) {
|
|
|
6289
6736
|
}
|
|
6290
6737
|
async function readTermsFile(filePath) {
|
|
6291
6738
|
try {
|
|
6292
|
-
return parseTermsFileContent(await
|
|
6739
|
+
return parseTermsFileContent(await readFile13(filePath, "utf8"));
|
|
6293
6740
|
} catch (error) {
|
|
6294
6741
|
console.warn(
|
|
6295
6742
|
`\u26A0 arcane: could not read --terms-file "${filePath}": ${error.message}`
|
|
@@ -6436,17 +6883,80 @@ Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
|
6436
6883
|
);
|
|
6437
6884
|
}
|
|
6438
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
|
+
|
|
6439
6949
|
// src/commands/report.ts
|
|
6440
6950
|
import { access as access5 } from "fs/promises";
|
|
6441
|
-
import { join as
|
|
6951
|
+
import { join as join18 } from "path";
|
|
6442
6952
|
|
|
6443
6953
|
// src/modules/show-report/generate.ts
|
|
6444
|
-
import { mkdir as mkdir8, readdir as
|
|
6445
|
-
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";
|
|
6446
6956
|
|
|
6447
6957
|
// src/modules/show-report/model.ts
|
|
6448
|
-
import { readFile as
|
|
6449
|
-
import { join as
|
|
6958
|
+
import { readFile as readFile14 } from "fs/promises";
|
|
6959
|
+
import { join as join16 } from "path";
|
|
6450
6960
|
|
|
6451
6961
|
// src/modules/show-report/plan-parser.ts
|
|
6452
6962
|
function parseFrontmatter(content) {
|
|
@@ -6716,7 +7226,7 @@ async function getCast(cwd, fromRef, toRef, generatedOutputs = []) {
|
|
|
6716
7226
|
// src/modules/show-report/model.ts
|
|
6717
7227
|
async function readOptional(path4) {
|
|
6718
7228
|
try {
|
|
6719
|
-
return await
|
|
7229
|
+
return await readFile14(path4, "utf8");
|
|
6720
7230
|
} catch {
|
|
6721
7231
|
return null;
|
|
6722
7232
|
}
|
|
@@ -6812,13 +7322,13 @@ function buildCorrections(ledgerRows, programName) {
|
|
|
6812
7322
|
};
|
|
6813
7323
|
}
|
|
6814
7324
|
async function buildShowReportModel(options) {
|
|
6815
|
-
const planAbsPath =
|
|
6816
|
-
const queueAbsPath =
|
|
7325
|
+
const planAbsPath = join16(options.rootDir, options.planRelPath);
|
|
7326
|
+
const queueAbsPath = join16(options.rootDir, options.queueRelPath);
|
|
6817
7327
|
const ledgerRelPath = "docs/verification-ledger.md";
|
|
6818
|
-
const ledgerAbsPath =
|
|
6819
|
-
const decisionsAbsPath =
|
|
7328
|
+
const ledgerAbsPath = join16(options.rootDir, ledgerRelPath);
|
|
7329
|
+
const decisionsAbsPath = join16(options.rootDir, "DECISIONS.md");
|
|
6820
7330
|
const [planContent, queueContent, ledgerContent, decisionsContent] = await Promise.all([
|
|
6821
|
-
|
|
7331
|
+
readFile14(planAbsPath, "utf8"),
|
|
6822
7332
|
readOptional(queueAbsPath),
|
|
6823
7333
|
readOptional(ledgerAbsPath),
|
|
6824
7334
|
readOptional(decisionsAbsPath)
|
|
@@ -6987,7 +7497,7 @@ function toPosix(path4) {
|
|
|
6987
7497
|
async function programFromPlan(rootDir, slug, planRelPath) {
|
|
6988
7498
|
let planContent;
|
|
6989
7499
|
try {
|
|
6990
|
-
planContent = await
|
|
7500
|
+
planContent = await readFile15(join17(rootDir, planRelPath), "utf8");
|
|
6991
7501
|
} catch {
|
|
6992
7502
|
return { skipped: { slug, reason: `no PLAN.md at ${planRelPath}` } };
|
|
6993
7503
|
}
|
|
@@ -6999,16 +7509,16 @@ async function programFromPlan(rootDir, slug, planRelPath) {
|
|
|
6999
7509
|
program: {
|
|
7000
7510
|
slug,
|
|
7001
7511
|
planRelPath,
|
|
7002
|
-
queueRelPath: toPosix(
|
|
7512
|
+
queueRelPath: toPosix(join17(dirname10(planRelPath), "OPERATOR-QUEUE.md")),
|
|
7003
7513
|
ledgerProgramName: programNameFromTitle(frontmatter.title ?? slug)
|
|
7004
7514
|
}
|
|
7005
7515
|
};
|
|
7006
7516
|
}
|
|
7007
7517
|
async function discoverPrograms(rootDir) {
|
|
7008
|
-
const plansDir =
|
|
7518
|
+
const plansDir = join17(rootDir, "docs", "plans");
|
|
7009
7519
|
let entries;
|
|
7010
7520
|
try {
|
|
7011
|
-
entries = (await
|
|
7521
|
+
entries = (await readdir11(plansDir, { withFileTypes: true })).filter((e) => e.isDirectory());
|
|
7012
7522
|
} catch (error) {
|
|
7013
7523
|
if (error.code === "ENOENT") return { programs: [], skipped: [] };
|
|
7014
7524
|
throw error;
|
|
@@ -7032,14 +7542,14 @@ function normalizeLineEndings2(content) {
|
|
|
7032
7542
|
}
|
|
7033
7543
|
async function readOptionalFile(path4) {
|
|
7034
7544
|
try {
|
|
7035
|
-
return await
|
|
7545
|
+
return await readFile15(path4, "utf8");
|
|
7036
7546
|
} catch (error) {
|
|
7037
7547
|
if (error.code === "ENOENT") return null;
|
|
7038
7548
|
throw error;
|
|
7039
7549
|
}
|
|
7040
7550
|
}
|
|
7041
7551
|
async function generateReport(rootDir, program2, templateHtml) {
|
|
7042
|
-
const planContent = await
|
|
7552
|
+
const planContent = await readFile15(join17(rootDir, program2.planRelPath), "utf8");
|
|
7043
7553
|
const frontmatter = parseFrontmatter(planContent);
|
|
7044
7554
|
const model = await buildShowReportModel({
|
|
7045
7555
|
rootDir,
|
|
@@ -7057,7 +7567,7 @@ async function generateReport(rootDir, program2, templateHtml) {
|
|
|
7057
7567
|
};
|
|
7058
7568
|
}
|
|
7059
7569
|
async function runReportGeneration(options) {
|
|
7060
|
-
const templateHtml = await
|
|
7570
|
+
const templateHtml = await readFile15(options.templatePath, "utf8");
|
|
7061
7571
|
const drifted = [];
|
|
7062
7572
|
const repaired = [];
|
|
7063
7573
|
const unwritten = [];
|
|
@@ -7072,12 +7582,12 @@ async function runReportGeneration(options) {
|
|
|
7072
7582
|
[`${outRel}/show-report.html`, generated.html]
|
|
7073
7583
|
];
|
|
7074
7584
|
for (const [relPath, expected] of outputs) {
|
|
7075
|
-
const actual = await readOptionalFile(
|
|
7585
|
+
const actual = await readOptionalFile(join17(options.rootDir, relPath));
|
|
7076
7586
|
if (actual === null || normalizeLineEndings2(actual) !== normalizeLineEndings2(expected)) {
|
|
7077
7587
|
drifted.push(`${relPath} does not match a regeneration from its sources.`);
|
|
7078
7588
|
if (options.mode === "write") {
|
|
7079
7589
|
await mkdir8(outAbs, { recursive: true });
|
|
7080
|
-
await writeFile11(
|
|
7590
|
+
await writeFile11(join17(options.rootDir, relPath), expected, "utf8");
|
|
7081
7591
|
repaired.push(relPath);
|
|
7082
7592
|
}
|
|
7083
7593
|
}
|
|
@@ -7088,7 +7598,7 @@ async function runReportGeneration(options) {
|
|
|
7088
7598
|
|
|
7089
7599
|
// src/commands/report.ts
|
|
7090
7600
|
async function runReport(targetDir, options = {}, assetsDir) {
|
|
7091
|
-
const templatePath =
|
|
7601
|
+
const templatePath = join18(assetsDir, "report", "show-report.template.html");
|
|
7092
7602
|
try {
|
|
7093
7603
|
await access5(templatePath);
|
|
7094
7604
|
} catch {
|
|
@@ -7151,13 +7661,29 @@ async function runReport(targetDir, options = {}, assetsDir) {
|
|
|
7151
7661
|
import { realpathSync } from "fs";
|
|
7152
7662
|
var require3 = createRequire2(import.meta.url);
|
|
7153
7663
|
var pkg = require3("../package.json");
|
|
7154
|
-
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ??
|
|
7664
|
+
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join19(dirname11(fileURLToPath(import.meta.url)), "assets");
|
|
7155
7665
|
var program = new Command();
|
|
7156
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)";
|
|
7157
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)";
|
|
7158
7668
|
function targetDirFor(opts) {
|
|
7159
7669
|
return opts.user ? userTierRoot() : process.cwd();
|
|
7160
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
|
+
}
|
|
7161
7687
|
program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
|
|
7162
7688
|
if (program.args.length > 0) {
|
|
7163
7689
|
const [attempted] = program.args;
|
|
@@ -7177,14 +7703,17 @@ program.name("spell").description("Arcane framework CLI \u2014 scaffold and mana
|
|
|
7177
7703
|
await printWelcome(pkg.version, process.cwd());
|
|
7178
7704
|
program.outputHelp();
|
|
7179
7705
|
});
|
|
7180
|
-
|
|
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(
|
|
7181
7709
|
async (opts) => {
|
|
7182
7710
|
await runInit(
|
|
7183
7711
|
{
|
|
7184
7712
|
profile: opts.profile,
|
|
7185
7713
|
force: opts.force,
|
|
7186
7714
|
dryRun: opts.dryRun,
|
|
7187
|
-
user: opts.user
|
|
7715
|
+
user: opts.user,
|
|
7716
|
+
manifestFlags: manifestFlagsFrom(opts)
|
|
7188
7717
|
},
|
|
7189
7718
|
targetDirFor(opts),
|
|
7190
7719
|
ASSETS_DIR,
|
|
@@ -7203,12 +7732,14 @@ program.command("add <component>").description("Add a component to an existing A
|
|
|
7203
7732
|
);
|
|
7204
7733
|
}
|
|
7205
7734
|
);
|
|
7206
|
-
|
|
7207
|
-
"--
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
"--
|
|
7211
|
-
|
|
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
|
+
)
|
|
7212
7743
|
).action(async (opts) => {
|
|
7213
7744
|
if (opts.defaultScope !== void 0) {
|
|
7214
7745
|
if (!opts.user) {
|
|
@@ -7229,6 +7760,7 @@ program.command("update").description("Update installed components to the curren
|
|
|
7229
7760
|
dryRun: opts.dryRun,
|
|
7230
7761
|
prune: opts.prune,
|
|
7231
7762
|
user: opts.user,
|
|
7763
|
+
manifestFlags: manifestFlagsFrom(opts),
|
|
7232
7764
|
...opts.defaultScope === void 0 ? {} : { defaultScope: opts.defaultScope }
|
|
7233
7765
|
},
|
|
7234
7766
|
targetDirFor(opts),
|
|
@@ -7242,6 +7774,9 @@ program.command("status").description("Show installed components and check for u
|
|
|
7242
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) => {
|
|
7243
7775
|
await runUninstall({ yes: opts.yes, dryRun: opts.dryRun, user: opts.user }, targetDirFor(opts));
|
|
7244
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
|
+
});
|
|
7245
7780
|
program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
|
|
7246
7781
|
await runUnblockPush(process.cwd());
|
|
7247
7782
|
});
|