arcane-cli 0.17.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/.arcane/governance/git-conventions.md +9 -2
- package/dist/assets/.arcane/governance/records-conventions.md +118 -0
- package/dist/assets/.arcane/governance/universal-agent-rules.md +26 -0
- package/dist/assets/.claude/commands/spell-adopt-docs.md +9 -0
- package/dist/assets/.github/prompts/spell-adopt-docs.prompt.md +157 -0
- package/dist/assets/.github/prompts/spell-close-session.prompt.md +4 -0
- package/dist/assets/docs-baseline/gitattributes +53 -0
- package/dist/assets/docs-baseline/gitignore +15 -0
- package/dist/index.js +799 -146
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -6,11 +6,11 @@ const require = __createRequire(import.meta.url);
|
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { createRequire as createRequire2 } from "module";
|
|
8
8
|
import { fileURLToPath } from "url";
|
|
9
|
-
import { dirname as dirname3, join as
|
|
9
|
+
import { dirname as dirname3, join as join12 } from "path";
|
|
10
10
|
|
|
11
11
|
// src/commands/init.ts
|
|
12
|
-
import { select as select2, confirm } from "@inquirer/prompts";
|
|
13
|
-
import { join as
|
|
12
|
+
import { select as select2, confirm, input as input2 } from "@inquirer/prompts";
|
|
13
|
+
import { join as join5 } from "path";
|
|
14
14
|
import chalk2 from "chalk";
|
|
15
15
|
|
|
16
16
|
// src/modules/copier.ts
|
|
@@ -98,6 +98,20 @@ var ManifestInvalidFieldError = class extends Error {
|
|
|
98
98
|
var MANIFEST_FILE = ".arcane.json";
|
|
99
99
|
var VALID_TRACKING_MODES = ["internal", "external"];
|
|
100
100
|
var VALID_EXTERNAL_PROVIDERS = ["ado", "jira", "other"];
|
|
101
|
+
var VALID_CONTENT_SENSITIVITY = ["standard", "sensitive"];
|
|
102
|
+
var VALID_PUSH_POLICIES = ["open", "guarded", "blocked"];
|
|
103
|
+
function isValidSubjectRoot(value) {
|
|
104
|
+
const invalid = value.trim() === "" || path2.isAbsolute(value) || // Windows drive-relative ("C:docs") and UNC-ish values also escape.
|
|
105
|
+
/^[a-zA-Z]:/.test(value) || value.startsWith("\\\\") || value.startsWith("\\") || value.split(/[\\/]/).includes("..") || // A stored subject_root is interpolated into agent prompts downstream, so
|
|
106
|
+
// keep it a single path-shaped token rather than a multi-line payload.
|
|
107
|
+
/[\r\n\0]/.test(value);
|
|
108
|
+
return !invalid;
|
|
109
|
+
}
|
|
110
|
+
function validateSubjectRoot(value, filePath) {
|
|
111
|
+
if (!isValidSubjectRoot(value)) {
|
|
112
|
+
throw new ManifestInvalidFieldError(filePath, "subject_root", value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
101
115
|
function manifestPath(targetDir) {
|
|
102
116
|
return path2.join(targetDir, MANIFEST_FILE);
|
|
103
117
|
}
|
|
@@ -108,6 +122,22 @@ function validateTrackingFields(manifest, filePath) {
|
|
|
108
122
|
if (manifest.external_provider !== void 0 && manifest.external_provider !== null && !VALID_EXTERNAL_PROVIDERS.includes(manifest.external_provider)) {
|
|
109
123
|
throw new ManifestInvalidFieldError(filePath, "external_provider", manifest.external_provider);
|
|
110
124
|
}
|
|
125
|
+
if (manifest.content_sensitivity !== void 0 && !VALID_CONTENT_SENSITIVITY.includes(manifest.content_sensitivity)) {
|
|
126
|
+
throw new ManifestInvalidFieldError(
|
|
127
|
+
filePath,
|
|
128
|
+
"content_sensitivity",
|
|
129
|
+
manifest.content_sensitivity
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (manifest.push_policy !== void 0 && !VALID_PUSH_POLICIES.includes(manifest.push_policy)) {
|
|
133
|
+
throw new ManifestInvalidFieldError(filePath, "push_policy", manifest.push_policy);
|
|
134
|
+
}
|
|
135
|
+
if (manifest.subject_root !== void 0 && manifest.subject_root !== null) {
|
|
136
|
+
if (typeof manifest.subject_root !== "string") {
|
|
137
|
+
throw new ManifestInvalidFieldError(filePath, "subject_root", manifest.subject_root);
|
|
138
|
+
}
|
|
139
|
+
validateSubjectRoot(manifest.subject_root, filePath);
|
|
140
|
+
}
|
|
111
141
|
}
|
|
112
142
|
async function readManifest(targetDir) {
|
|
113
143
|
const filePath = manifestPath(targetDir);
|
|
@@ -155,15 +185,35 @@ var PROFILE_CONFIGS = [
|
|
|
155
185
|
id: "lite",
|
|
156
186
|
displayName: "Essentials",
|
|
157
187
|
description: "Spells + core governance \u2014 fast start",
|
|
158
|
-
components: [
|
|
188
|
+
components: [
|
|
189
|
+
"spells-session",
|
|
190
|
+
"spells-capture",
|
|
191
|
+
"spells-delivery",
|
|
192
|
+
"spells-review",
|
|
193
|
+
"spells-planning",
|
|
194
|
+
"spells-build",
|
|
195
|
+
"spells-venture",
|
|
196
|
+
"spells-meta",
|
|
197
|
+
"agent-output-instructions",
|
|
198
|
+
"git-conventions",
|
|
199
|
+
"testing-standards",
|
|
200
|
+
"framework-decisions",
|
|
201
|
+
"session-continuity"
|
|
202
|
+
]
|
|
159
203
|
},
|
|
160
204
|
{
|
|
161
205
|
id: "methodology",
|
|
162
206
|
displayName: "Methodology Suite",
|
|
163
207
|
description: "Spells + governance \u2014 full methodology without security/infra docs",
|
|
164
208
|
components: [
|
|
165
|
-
"
|
|
166
|
-
"
|
|
209
|
+
"spells-session",
|
|
210
|
+
"spells-capture",
|
|
211
|
+
"spells-delivery",
|
|
212
|
+
"spells-review",
|
|
213
|
+
"spells-planning",
|
|
214
|
+
"spells-build",
|
|
215
|
+
"spells-venture",
|
|
216
|
+
"spells-meta",
|
|
167
217
|
"agent-output-instructions",
|
|
168
218
|
"git-conventions",
|
|
169
219
|
"testing-standards",
|
|
@@ -175,6 +225,44 @@ var PROFILE_CONFIGS = [
|
|
|
175
225
|
"spell-authoring-standards"
|
|
176
226
|
]
|
|
177
227
|
},
|
|
228
|
+
{
|
|
229
|
+
id: "docs",
|
|
230
|
+
displayName: "Docs / Records",
|
|
231
|
+
description: "Documentation and records repositories \u2014 no code, test, or deployment workflows",
|
|
232
|
+
components: [
|
|
233
|
+
// Deliberate exclusions, per docs-mode PRD MH-01: "spells-build"
|
|
234
|
+
// (implementation, tests, stack experts, release, deployment, PRD
|
|
235
|
+
// enchantment, asset tooling), "spells-review" (adversarial CODE review
|
|
236
|
+
// -- its own workflow validates test coverage against source, which a
|
|
237
|
+
// docs repo has neither of), and "spells-venture" (hub-only).
|
|
238
|
+
//
|
|
239
|
+
// MH-01 requires every retained spell to complete its core workflow
|
|
240
|
+
// without source code, tests, CI, or an external tracker. Asserted --
|
|
241
|
+
// exclusions included, which MH-01 requires explicitly -- in
|
|
242
|
+
// test/docs-profile-registry-split.test.ts.
|
|
243
|
+
"spells-session",
|
|
244
|
+
"spells-capture",
|
|
245
|
+
"spells-delivery",
|
|
246
|
+
"spells-planning",
|
|
247
|
+
"spells-meta",
|
|
248
|
+
"spells-docs",
|
|
249
|
+
"docs-baseline",
|
|
250
|
+
"records-conventions",
|
|
251
|
+
"agent-output-instructions",
|
|
252
|
+
"git-conventions",
|
|
253
|
+
// Four retained docs spells link to agent-policies; it carries no code
|
|
254
|
+
// implication, so shipping it closes a dangling reference cheaply.
|
|
255
|
+
"agent-policies",
|
|
256
|
+
"framework-decisions",
|
|
257
|
+
"session-continuity",
|
|
258
|
+
"decision-documentation-standard",
|
|
259
|
+
"agent-work-queue-model",
|
|
260
|
+
"development-methodology",
|
|
261
|
+
"naming-conventions",
|
|
262
|
+
"universal-agent-rules",
|
|
263
|
+
"spell-authoring-standards"
|
|
264
|
+
]
|
|
265
|
+
},
|
|
178
266
|
{
|
|
179
267
|
id: "governance-only",
|
|
180
268
|
displayName: "Governance Only",
|
|
@@ -323,46 +411,159 @@ var COMPONENTS = [
|
|
|
323
411
|
description: "Copilot enforcement rules: PR link format, merge strategy, and branch naming",
|
|
324
412
|
files: [".github/instructions/agent-output.instructions.md"]
|
|
325
413
|
},
|
|
326
|
-
//
|
|
414
|
+
// Repository baseline files (EF-10 + EF-17). skipExisting: these are
|
|
415
|
+
// user-owned once written -- a repo with its own .gitattributes has an
|
|
416
|
+
// intentional policy Arcane must not overwrite. Sources are stored under a
|
|
417
|
+
// non-dotfile path and mapped via sourceOverrides; see RegistryComponent.
|
|
418
|
+
{
|
|
419
|
+
name: "docs-baseline",
|
|
420
|
+
description: "Line-ending and binary-format baseline for document repositories (.gitattributes/.gitignore)",
|
|
421
|
+
files: [".gitattributes", ".gitignore"],
|
|
422
|
+
sourceOverrides: {
|
|
423
|
+
".gitattributes": "docs-baseline/gitattributes",
|
|
424
|
+
".gitignore": "docs-baseline/gitignore"
|
|
425
|
+
},
|
|
426
|
+
skipExisting: true,
|
|
427
|
+
initOnly: true
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: "records-conventions",
|
|
431
|
+
description: "Supersession, tombstone, and retention conventions for records repositories",
|
|
432
|
+
files: [".arcane/governance/records-conventions.md"]
|
|
433
|
+
},
|
|
434
|
+
// ─── Spells ────────────────────────────────────────────────────────────────
|
|
435
|
+
// Split from the former monolithic `spell-prompts` + `claude-commands` pair
|
|
436
|
+
// so a profile can select spells by capability (see ARC-0NN). Grouping lives
|
|
437
|
+
// here and only here -- every spell file stays flat and unrenamed on disk.
|
|
438
|
+
//
|
|
439
|
+
// Each component carries BOTH client formats of the same spell: the Copilot
|
|
440
|
+
// prompt (.github/prompts/*.prompt.md) and the Claude Code wrapper
|
|
441
|
+
// (.claude/commands/*.md). They are never independently selectable -- every
|
|
442
|
+
// profile that wanted one always wanted the other -- and pairing them here
|
|
443
|
+
// makes it structurally impossible for the two formats of one spell to drift
|
|
444
|
+
// apart across profiles.
|
|
327
445
|
{
|
|
328
|
-
name: "
|
|
329
|
-
description: "
|
|
446
|
+
name: "spells-session",
|
|
447
|
+
description: "Session lifecycle \u2014 open, close, commit, status, version",
|
|
330
448
|
files: [
|
|
449
|
+
".github/prompts/spell-open-session.prompt.md",
|
|
450
|
+
".claude/commands/spell-open-session.md",
|
|
451
|
+
".github/prompts/spell-close-session.prompt.md",
|
|
452
|
+
".claude/commands/spell-close-session.md",
|
|
453
|
+
".github/prompts/spell-commit-work.prompt.md",
|
|
454
|
+
".claude/commands/spell-commit-work.md",
|
|
455
|
+
".github/prompts/spell-status.prompt.md",
|
|
456
|
+
".claude/commands/spell-status.md",
|
|
331
457
|
".github/prompts/spell-arcane-version.prompt.md",
|
|
332
|
-
".
|
|
458
|
+
".claude/commands/spell-arcane-version.md"
|
|
459
|
+
]
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
name: "spells-capture",
|
|
463
|
+
description: "Idea, todo, feedback and explanation capture",
|
|
464
|
+
files: [
|
|
465
|
+
".github/prompts/spell-save-idea.prompt.md",
|
|
466
|
+
".claude/commands/spell-save-idea.md",
|
|
467
|
+
".github/prompts/spell-todo.prompt.md",
|
|
468
|
+
".claude/commands/spell-todo.md",
|
|
469
|
+
".github/prompts/spell-feedback.prompt.md",
|
|
470
|
+
".claude/commands/spell-feedback.md",
|
|
471
|
+
".github/prompts/spell-suggest-feature.prompt.md",
|
|
472
|
+
".claude/commands/spell-suggest-feature.md",
|
|
473
|
+
".github/prompts/spell-document.prompt.md",
|
|
474
|
+
".claude/commands/spell-document.md",
|
|
333
475
|
".github/prompts/spell-brainstorm.prompt.md",
|
|
476
|
+
".claude/commands/spell-brainstorm.md",
|
|
477
|
+
".github/prompts/spell-explain-concept.prompt.md",
|
|
478
|
+
".claude/commands/spell-explain-concept.md"
|
|
479
|
+
]
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: "spells-delivery",
|
|
483
|
+
description: "Pull-request lifecycle \u2014 open a PR, respond to review feedback",
|
|
484
|
+
files: [
|
|
485
|
+
".github/prompts/spell-create-pull-request.prompt.md",
|
|
486
|
+
".claude/commands/spell-create-pull-request.md",
|
|
487
|
+
".github/prompts/spell-address-review.prompt.md",
|
|
488
|
+
".claude/commands/spell-address-review.md"
|
|
489
|
+
]
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
name: "spells-review",
|
|
493
|
+
description: "Adversarial code review \u2014 requires source and tests, so excluded from docs-only profiles",
|
|
494
|
+
files: [
|
|
495
|
+
".github/prompts/spell-review.prompt.md",
|
|
496
|
+
".claude/commands/spell-review.md",
|
|
497
|
+
".github/prompts/spell-review-batch.prompt.md",
|
|
498
|
+
".claude/commands/spell-review-batch.md"
|
|
499
|
+
]
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
name: "spells-planning",
|
|
503
|
+
description: "PRD, architecture, scoping and product review",
|
|
504
|
+
files: [
|
|
505
|
+
".github/prompts/spell-plan.prompt.md",
|
|
506
|
+
".claude/commands/spell-plan.md",
|
|
507
|
+
".github/prompts/spell-architect.prompt.md",
|
|
508
|
+
".claude/commands/spell-architect.md",
|
|
334
509
|
".github/prompts/spell-scope.prompt.md",
|
|
335
|
-
".
|
|
510
|
+
".claude/commands/spell-scope.md",
|
|
511
|
+
".github/prompts/spell-product-review.prompt.md",
|
|
512
|
+
".claude/commands/spell-product-review.md"
|
|
513
|
+
]
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
name: "spells-build",
|
|
517
|
+
description: "Code and product delivery \u2014 implementation, tests, stack experts, release, deployment, asset tooling",
|
|
518
|
+
files: [
|
|
519
|
+
".github/prompts/spell-implement.prompt.md",
|
|
520
|
+
".claude/commands/spell-implement.md",
|
|
521
|
+
".github/prompts/spell-test.prompt.md",
|
|
522
|
+
".claude/commands/spell-test.md",
|
|
523
|
+
".github/prompts/spell-full-cycle.prompt.md",
|
|
524
|
+
".claude/commands/spell-full-cycle.md",
|
|
336
525
|
".github/prompts/spell-bug.prompt.md",
|
|
526
|
+
".claude/commands/spell-bug.md",
|
|
337
527
|
".github/prompts/spell-bump.prompt.md",
|
|
338
|
-
".
|
|
339
|
-
".github/prompts/spell-close-session.prompt.md",
|
|
340
|
-
".github/prompts/spell-commit-work.prompt.md",
|
|
528
|
+
".claude/commands/spell-bump.md",
|
|
341
529
|
".github/prompts/spell-dotnet-expert.prompt.md",
|
|
342
|
-
".
|
|
343
|
-
".github/prompts/spell-explain-concept.prompt.md",
|
|
344
|
-
".github/prompts/spell-full-cycle.prompt.md",
|
|
345
|
-
".github/prompts/spell-generate-bot-icons.prompt.md",
|
|
346
|
-
".github/prompts/spell-implement.prompt.md",
|
|
347
|
-
".github/prompts/spell-open-session.prompt.md",
|
|
348
|
-
".github/prompts/spell-plan.prompt.md",
|
|
349
|
-
".github/prompts/spell-present-arcane.prompt.md",
|
|
350
|
-
".github/prompts/spell-product-review.prompt.md",
|
|
351
|
-
".github/prompts/spell-review.prompt.md",
|
|
530
|
+
".claude/commands/spell-dotnet-expert.md",
|
|
352
531
|
".github/prompts/spell-security-review.prompt.md",
|
|
532
|
+
".claude/commands/spell-security-review.md",
|
|
353
533
|
".github/prompts/spell-ship.prompt.md",
|
|
354
|
-
".
|
|
355
|
-
".github/prompts/spell-
|
|
356
|
-
".
|
|
357
|
-
|
|
358
|
-
".
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
534
|
+
".claude/commands/spell-ship.md",
|
|
535
|
+
".github/prompts/spell-enchant.prompt.md",
|
|
536
|
+
".claude/commands/spell-enchant.md",
|
|
537
|
+
".github/prompts/spell-generate-bot-icons.prompt.md",
|
|
538
|
+
".claude/commands/spell-generate-bot-icons.md"
|
|
539
|
+
]
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
name: "spells-docs",
|
|
543
|
+
description: "Documentation and records workflows \u2014 adopt an existing document tree",
|
|
544
|
+
files: [
|
|
545
|
+
".github/prompts/spell-adopt-docs.prompt.md",
|
|
546
|
+
".claude/commands/spell-adopt-docs.md"
|
|
547
|
+
]
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: "spells-venture",
|
|
551
|
+
description: "Hub-only venture management",
|
|
552
|
+
files: [
|
|
553
|
+
".github/prompts/spell-summon-venture.prompt.md",
|
|
554
|
+
".claude/commands/spell-summon-venture.md",
|
|
362
555
|
".github/prompts/spell-manifest.prompt.md",
|
|
363
|
-
".
|
|
364
|
-
|
|
365
|
-
|
|
556
|
+
".claude/commands/spell-manifest.md"
|
|
557
|
+
]
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
name: "spells-meta",
|
|
561
|
+
description: "Arcane-about-Arcane \u2014 presentation and documentation drift detection",
|
|
562
|
+
files: [
|
|
563
|
+
".github/prompts/spell-present-arcane.prompt.md",
|
|
564
|
+
".claude/commands/spell-present-arcane.md",
|
|
565
|
+
".github/prompts/spell-check-drift.prompt.md",
|
|
566
|
+
".claude/commands/spell-check-drift.md"
|
|
366
567
|
]
|
|
367
568
|
},
|
|
368
569
|
// Templates
|
|
@@ -396,53 +597,11 @@ var COMPONENTS = [
|
|
|
396
597
|
name: "agent-definitions",
|
|
397
598
|
description: "Agent policy reference template (for governance-only setups \u2014 use `spell agents init` for the full agent system)",
|
|
398
599
|
files: [".arcane/agents/agent-policies-template.md"]
|
|
399
|
-
}
|
|
600
|
+
}
|
|
400
601
|
// NOTE: the static `agent-files` component was retired — `.github/agents/*.agent.md`
|
|
401
602
|
// files are generated per-roster by `spell agents init` / `spell agents sync`, so
|
|
402
603
|
// installing a fixed-name set via `spell init` shipped stale names and collided
|
|
403
604
|
// with the generated output.
|
|
404
|
-
// Claude commands
|
|
405
|
-
{
|
|
406
|
-
name: "claude-commands",
|
|
407
|
-
description: "Claude Code slash command wrappers for all 34 Arcane spells",
|
|
408
|
-
files: [
|
|
409
|
-
".claude/commands/spell-arcane-version.md",
|
|
410
|
-
".claude/commands/spell-architect.md",
|
|
411
|
-
".claude/commands/spell-brainstorm.md",
|
|
412
|
-
".claude/commands/spell-scope.md",
|
|
413
|
-
".claude/commands/spell-summon-venture.md",
|
|
414
|
-
".claude/commands/spell-bug.md",
|
|
415
|
-
".claude/commands/spell-bump.md",
|
|
416
|
-
".claude/commands/spell-check-drift.md",
|
|
417
|
-
".claude/commands/spell-close-session.md",
|
|
418
|
-
".claude/commands/spell-commit-work.md",
|
|
419
|
-
".claude/commands/spell-dotnet-expert.md",
|
|
420
|
-
".claude/commands/spell-enchant.md",
|
|
421
|
-
".claude/commands/spell-explain-concept.md",
|
|
422
|
-
".claude/commands/spell-full-cycle.md",
|
|
423
|
-
".claude/commands/spell-generate-bot-icons.md",
|
|
424
|
-
".claude/commands/spell-implement.md",
|
|
425
|
-
".claude/commands/spell-open-session.md",
|
|
426
|
-
".claude/commands/spell-plan.md",
|
|
427
|
-
".claude/commands/spell-present-arcane.md",
|
|
428
|
-
".claude/commands/spell-product-review.md",
|
|
429
|
-
".claude/commands/spell-review.md",
|
|
430
|
-
".claude/commands/spell-security-review.md",
|
|
431
|
-
".claude/commands/spell-ship.md",
|
|
432
|
-
".claude/commands/spell-suggest-feature.md",
|
|
433
|
-
".claude/commands/spell-test.md",
|
|
434
|
-
".claude/commands/spell-todo.md",
|
|
435
|
-
// Workflow spell additions — PR lifecycle, status, capture, feedback
|
|
436
|
-
".claude/commands/spell-address-review.md",
|
|
437
|
-
".claude/commands/spell-create-pull-request.md",
|
|
438
|
-
".claude/commands/spell-document.md",
|
|
439
|
-
".claude/commands/spell-feedback.md",
|
|
440
|
-
".claude/commands/spell-manifest.md",
|
|
441
|
-
".claude/commands/spell-save-idea.md",
|
|
442
|
-
".claude/commands/spell-status.md",
|
|
443
|
-
".claude/commands/spell-review-batch.md"
|
|
444
|
-
]
|
|
445
|
-
}
|
|
446
605
|
];
|
|
447
606
|
function buildProfileMap() {
|
|
448
607
|
const allNames = COMPONENTS.map((c) => c.name);
|
|
@@ -454,6 +613,23 @@ function buildProfileMap() {
|
|
|
454
613
|
}
|
|
455
614
|
var PROFILE_MAP = buildProfileMap();
|
|
456
615
|
var componentIndex = new Map(COMPONENTS.map((c) => [c.name, c]));
|
|
616
|
+
var SPELL_COMPONENT_NAMES = COMPONENTS.map((c) => c.name).filter(
|
|
617
|
+
(n) => n.startsWith("spells-")
|
|
618
|
+
);
|
|
619
|
+
var LEGACY_MONOLITH_REPLACEMENTS = [
|
|
620
|
+
"spells-session",
|
|
621
|
+
"spells-capture",
|
|
622
|
+
"spells-delivery",
|
|
623
|
+
"spells-review",
|
|
624
|
+
"spells-planning",
|
|
625
|
+
"spells-build",
|
|
626
|
+
"spells-venture",
|
|
627
|
+
"spells-meta"
|
|
628
|
+
];
|
|
629
|
+
var LEGACY_COMPONENT_MIGRATIONS = Object.assign(/* @__PURE__ */ Object.create(null), {
|
|
630
|
+
"spell-prompts": LEGACY_MONOLITH_REPLACEMENTS,
|
|
631
|
+
"claude-commands": LEGACY_MONOLITH_REPLACEMENTS
|
|
632
|
+
});
|
|
457
633
|
function getComponent(name) {
|
|
458
634
|
const component = componentIndex.get(name);
|
|
459
635
|
if (!component) {
|
|
@@ -496,7 +672,7 @@ var WORDMARK = [
|
|
|
496
672
|
var INDENT = " ";
|
|
497
673
|
var LOCKUP_WIDTH = INDENT.length + Math.max(...WORDMARK.map((l) => l.length));
|
|
498
674
|
function sleep(ms) {
|
|
499
|
-
return new Promise((
|
|
675
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
500
676
|
}
|
|
501
677
|
function isAnimatable() {
|
|
502
678
|
return !!process.stdout.isTTY && process.env["TERM"] !== "dumb" && process.env["CI"] !== "true" && process.env["ARCANE_DISABLE_ANIMATION"] !== "true" && (process.stdout.columns ?? 80) >= LOCKUP_WIDTH;
|
|
@@ -1618,6 +1794,10 @@ async function runAgentsList(targetDir) {
|
|
|
1618
1794
|
console.log();
|
|
1619
1795
|
}
|
|
1620
1796
|
|
|
1797
|
+
// src/modules/push-safety.ts
|
|
1798
|
+
import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile5, chmod } from "fs/promises";
|
|
1799
|
+
import { join as join4 } from "path";
|
|
1800
|
+
|
|
1621
1801
|
// src/modules/git.ts
|
|
1622
1802
|
import { execFile } from "child_process";
|
|
1623
1803
|
var GitTimeoutError = class extends Error {
|
|
@@ -1713,7 +1893,7 @@ function buildGitEnv() {
|
|
|
1713
1893
|
function runGit(cwd, args, options = {}) {
|
|
1714
1894
|
const commandClass = options.commandClass ?? classifyGitCommand(args);
|
|
1715
1895
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUTS_MS[commandClass] ?? DEFAULT_TIMEOUTS_MS.write;
|
|
1716
|
-
return new Promise((
|
|
1896
|
+
return new Promise((resolve2, reject) => {
|
|
1717
1897
|
const child = execFile(
|
|
1718
1898
|
"git",
|
|
1719
1899
|
args,
|
|
@@ -1747,7 +1927,7 @@ function runGit(cwd, args, options = {}) {
|
|
|
1747
1927
|
reject(error);
|
|
1748
1928
|
return;
|
|
1749
1929
|
}
|
|
1750
|
-
|
|
1930
|
+
resolve2({ stdout, stderr });
|
|
1751
1931
|
}
|
|
1752
1932
|
);
|
|
1753
1933
|
child.stdin?.end();
|
|
@@ -1836,28 +2016,169 @@ async function ensureLocalPullRebase(cwd) {
|
|
|
1836
2016
|
}
|
|
1837
2017
|
}
|
|
1838
2018
|
|
|
2019
|
+
// src/modules/push-safety.ts
|
|
2020
|
+
var DISABLED_PUSH_URL = "arcane-push-blocked://this-repository-is-push-blocked";
|
|
2021
|
+
var ARCANE_HOOKS_DIR = ".arcane/hooks";
|
|
2022
|
+
async function readHooksPath(cwd) {
|
|
2023
|
+
let effective;
|
|
2024
|
+
try {
|
|
2025
|
+
const { stdout } = await runGit(cwd, ["config", "--get", "core.hooksPath"]);
|
|
2026
|
+
effective = stdout.trim();
|
|
2027
|
+
} catch {
|
|
2028
|
+
return void 0;
|
|
2029
|
+
}
|
|
2030
|
+
if (effective === "") return void 0;
|
|
2031
|
+
let local = "";
|
|
2032
|
+
try {
|
|
2033
|
+
const { stdout } = await runGit(cwd, ["config", "--local", "--get", "core.hooksPath"]);
|
|
2034
|
+
local = stdout.trim();
|
|
2035
|
+
} catch {
|
|
2036
|
+
}
|
|
2037
|
+
return { value: effective, scope: local === effective && local !== "" ? "local" : "inherited" };
|
|
2038
|
+
}
|
|
2039
|
+
var HOOK_BODY = `#!/bin/sh
|
|
2040
|
+
# Installed by Arcane because this repository's .arcane.json sets
|
|
2041
|
+
# push_policy: "blocked".
|
|
2042
|
+
#
|
|
2043
|
+
# This is deliberately not tamper-proof. It guards against an accidental push
|
|
2044
|
+
# -- the wrong remote, muscle memory, an unsupervised agent -- not against a
|
|
2045
|
+
# determined operator, who can always bypass it.
|
|
2046
|
+
#
|
|
2047
|
+
# To push legitimately, undo the policy first:
|
|
2048
|
+
#
|
|
2049
|
+
# spell unblock-push
|
|
2050
|
+
#
|
|
2051
|
+
# That command is interactive-only and asks you to confirm the repository by
|
|
2052
|
+
# name. Do not work around this hook by other means: the manifest would then
|
|
2053
|
+
# claim a protection this repository no longer has.
|
|
2054
|
+
echo "arcane: push blocked (push_policy: blocked). Run 'spell unblock-push' to undo." >&2
|
|
2055
|
+
exit 1
|
|
2056
|
+
`;
|
|
2057
|
+
function hookFilePath(cwd) {
|
|
2058
|
+
return join4(cwd, ARCANE_HOOKS_DIR, "pre-push");
|
|
2059
|
+
}
|
|
2060
|
+
async function installPrePushHook(cwd) {
|
|
2061
|
+
const existing = await readHooksPath(cwd);
|
|
2062
|
+
if (existing !== void 0 && existing.value !== ARCANE_HOOKS_DIR) {
|
|
2063
|
+
return {
|
|
2064
|
+
status: "refused-foreign-hooks-path",
|
|
2065
|
+
existing: existing.value,
|
|
2066
|
+
scope: existing.scope
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
const hookPath = hookFilePath(cwd);
|
|
2070
|
+
if (existing?.value === ARCANE_HOOKS_DIR && await fileExists(hookPath)) {
|
|
2071
|
+
const current = await readFile4(hookPath, "utf-8");
|
|
2072
|
+
if (current === HOOK_BODY) return { status: "already-ours" };
|
|
2073
|
+
}
|
|
2074
|
+
await mkdir4(join4(cwd, ARCANE_HOOKS_DIR), { recursive: true });
|
|
2075
|
+
await writeFile5(hookPath, HOOK_BODY, "utf-8");
|
|
2076
|
+
await chmod(hookPath, 493);
|
|
2077
|
+
await runGit(cwd, ["config", "--local", "core.hooksPath", ARCANE_HOOKS_DIR]);
|
|
2078
|
+
return { status: "installed", path: hookPath };
|
|
2079
|
+
}
|
|
2080
|
+
async function isHookEnforced(cwd) {
|
|
2081
|
+
const hooksPath = await readHooksPath(cwd);
|
|
2082
|
+
if (hooksPath?.value !== ARCANE_HOOKS_DIR) return false;
|
|
2083
|
+
return fileExists(hookFilePath(cwd));
|
|
2084
|
+
}
|
|
2085
|
+
async function removePrePushHook(cwd) {
|
|
2086
|
+
const existing = await readHooksPath(cwd);
|
|
2087
|
+
if (existing?.value !== ARCANE_HOOKS_DIR || existing.scope !== "local") return;
|
|
2088
|
+
try {
|
|
2089
|
+
await runGit(cwd, ["config", "--local", "--unset", "core.hooksPath"]);
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
async function listRemotes(cwd) {
|
|
2094
|
+
try {
|
|
2095
|
+
const { stdout } = await runGit(cwd, ["remote"]);
|
|
2096
|
+
return stdout.split("\n").map((r) => r.trim()).filter(Boolean);
|
|
2097
|
+
} catch {
|
|
2098
|
+
return [];
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function disablePushUrls(cwd) {
|
|
2102
|
+
const results = [];
|
|
2103
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2104
|
+
let current;
|
|
2105
|
+
try {
|
|
2106
|
+
const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
|
|
2107
|
+
current = stdout.trim();
|
|
2108
|
+
} catch {
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
if (current === DISABLED_PUSH_URL) {
|
|
2112
|
+
results.push({ remote, status: "already-disabled" });
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
await runGit(cwd, ["config", "--local", `arcane.originalPushUrl.${remote}`, current]);
|
|
2116
|
+
await runGit(cwd, ["remote", "set-url", "--push", remote, DISABLED_PUSH_URL]);
|
|
2117
|
+
results.push({ remote, status: "disabled" });
|
|
2118
|
+
}
|
|
2119
|
+
return results;
|
|
2120
|
+
}
|
|
2121
|
+
async function undisabledRemotes(cwd) {
|
|
2122
|
+
const open = [];
|
|
2123
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2124
|
+
try {
|
|
2125
|
+
const { stdout } = await runGit(cwd, ["remote", "get-url", "--push", remote]);
|
|
2126
|
+
if (stdout.trim() !== DISABLED_PUSH_URL) open.push(remote);
|
|
2127
|
+
} catch {
|
|
2128
|
+
open.push(remote);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
return open;
|
|
2132
|
+
}
|
|
2133
|
+
async function restorePushUrls(cwd) {
|
|
2134
|
+
const restored = [];
|
|
2135
|
+
for (const remote of await listRemotes(cwd)) {
|
|
2136
|
+
let original = "";
|
|
2137
|
+
try {
|
|
2138
|
+
const { stdout } = await runGit(cwd, [
|
|
2139
|
+
"config",
|
|
2140
|
+
"--local",
|
|
2141
|
+
"--get",
|
|
2142
|
+
`arcane.originalPushUrl.${remote}`
|
|
2143
|
+
]);
|
|
2144
|
+
original = stdout.trim();
|
|
2145
|
+
} catch {
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
2148
|
+
if (original === "") continue;
|
|
2149
|
+
await runGit(cwd, ["remote", "set-url", "--push", remote, original]);
|
|
2150
|
+
try {
|
|
2151
|
+
await runGit(cwd, ["config", "--local", "--unset", `arcane.originalPushUrl.${remote}`]);
|
|
2152
|
+
} catch {
|
|
2153
|
+
}
|
|
2154
|
+
restored.push(remote);
|
|
2155
|
+
}
|
|
2156
|
+
return restored;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
1839
2159
|
// src/commands/init.ts
|
|
1840
|
-
var VALID_PROFILES = ["full", "lite", "governance-only", "methodology"];
|
|
2160
|
+
var VALID_PROFILES = ["full", "lite", "governance-only", "methodology", "docs"];
|
|
2161
|
+
var PROFILE_ICONS = {
|
|
2162
|
+
full: "\u{1F9D9}",
|
|
2163
|
+
lite: "\u26A1",
|
|
2164
|
+
docs: "\u{1F4D6}"
|
|
2165
|
+
};
|
|
2166
|
+
function isSpellComponent(name) {
|
|
2167
|
+
return name.startsWith("spells-");
|
|
2168
|
+
}
|
|
1841
2169
|
function categorizeComponents(components) {
|
|
1842
2170
|
const groups = [];
|
|
1843
|
-
const
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
label: "Copilot Spells",
|
|
1848
|
-
count: prompts.files.length
|
|
1849
|
-
});
|
|
2171
|
+
const spellFiles = components.filter((c) => isSpellComponent(c.name)).flatMap((c) => c.files);
|
|
2172
|
+
const promptCount = spellFiles.filter((f) => f.startsWith(".github/prompts/")).length;
|
|
2173
|
+
if (promptCount > 0) {
|
|
2174
|
+
groups.push({ icon: "\u2728", label: "Copilot Spells", count: promptCount });
|
|
1850
2175
|
}
|
|
1851
|
-
const
|
|
1852
|
-
if (
|
|
1853
|
-
groups.push({
|
|
1854
|
-
icon: "\u26A1",
|
|
1855
|
-
label: "Claude Spells",
|
|
1856
|
-
count: claude.files.length
|
|
1857
|
-
});
|
|
2176
|
+
const claudeCount = spellFiles.filter((f) => f.startsWith(".claude/commands/")).length;
|
|
2177
|
+
if (claudeCount > 0) {
|
|
2178
|
+
groups.push({ icon: "\u26A1", label: "Claude Spells", count: claudeCount });
|
|
1858
2179
|
}
|
|
1859
2180
|
const governance = components.filter(
|
|
1860
|
-
(c) => c.name
|
|
2181
|
+
(c) => !isSpellComponent(c.name) && c.name !== "venture-template" && c.name !== "agent-definitions" && c.files.some((f) => f.startsWith(".arcane/governance/"))
|
|
1861
2182
|
);
|
|
1862
2183
|
if (governance.length > 0) {
|
|
1863
2184
|
groups.push({
|
|
@@ -1963,7 +2284,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
1963
2284
|
const profileComponents = getProfile(p.id);
|
|
1964
2285
|
return {
|
|
1965
2286
|
value: p.id,
|
|
1966
|
-
name: `${p.id
|
|
2287
|
+
name: `${PROFILE_ICONS[p.id] ?? "\u{1F4CB}"} ${p.displayName} \u2014 ${p.description}`,
|
|
1967
2288
|
description: buildProfileDescription(profileComponents, p.id)
|
|
1968
2289
|
};
|
|
1969
2290
|
})
|
|
@@ -1997,7 +2318,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
1997
2318
|
for (const component of components) {
|
|
1998
2319
|
const installedFiles = [];
|
|
1999
2320
|
for (const file of component.files) {
|
|
2000
|
-
const srcPath =
|
|
2321
|
+
const srcPath = join5(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2001
2322
|
if (options.dryRun) {
|
|
2002
2323
|
printDryRun(`Would copy: ${file}`);
|
|
2003
2324
|
} else {
|
|
@@ -2014,7 +2335,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2014
2335
|
fileCount++;
|
|
2015
2336
|
}
|
|
2016
2337
|
for (const dir of component.directories ?? []) {
|
|
2017
|
-
const srcDirPath =
|
|
2338
|
+
const srcDirPath = join5(assetsDir, dir);
|
|
2018
2339
|
if (options.dryRun) {
|
|
2019
2340
|
printDryRun(`Would copy directory: ${dir}/`);
|
|
2020
2341
|
} else {
|
|
@@ -2039,7 +2360,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2039
2360
|
if (options.dryRun) {
|
|
2040
2361
|
if (hasAgents) {
|
|
2041
2362
|
const agentFiles = await import("fs").then(
|
|
2042
|
-
(fsModule) => fsModule.promises.readdir(
|
|
2363
|
+
(fsModule) => fsModule.promises.readdir(join5(assetsDir, "agents"))
|
|
2043
2364
|
);
|
|
2044
2365
|
const yamlFiles = agentFiles.filter((f) => f.endsWith(".yaml")).sort();
|
|
2045
2366
|
printDryRun("Would run agent setup (interactive):");
|
|
@@ -2062,7 +2383,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2062
2383
|
}
|
|
2063
2384
|
let tracking_mode;
|
|
2064
2385
|
let external_provider;
|
|
2065
|
-
if (profile === "governance-only" || profile === "methodology") {
|
|
2386
|
+
if (profile === "governance-only" || profile === "methodology" || profile === "docs") {
|
|
2066
2387
|
tracking_mode = "internal";
|
|
2067
2388
|
external_provider = null;
|
|
2068
2389
|
} else if (!options.profile) {
|
|
@@ -2087,16 +2408,110 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2087
2408
|
external_provider = null;
|
|
2088
2409
|
}
|
|
2089
2410
|
}
|
|
2411
|
+
let subject_root;
|
|
2412
|
+
if (profile === "docs" && !options.profile) {
|
|
2413
|
+
console.log();
|
|
2414
|
+
const shape = await select2({
|
|
2415
|
+
message: "What does this repository hold?",
|
|
2416
|
+
choices: [
|
|
2417
|
+
{
|
|
2418
|
+
value: "root",
|
|
2419
|
+
name: "One subject, at the repository root (documents sit alongside Arcane's files)"
|
|
2420
|
+
},
|
|
2421
|
+
{ value: "subdir", name: "One subject, in its own directory" },
|
|
2422
|
+
{ value: "portfolio", name: "Several subjects or ventures (decide per-document later)" }
|
|
2423
|
+
]
|
|
2424
|
+
});
|
|
2425
|
+
if (shape === "root") {
|
|
2426
|
+
subject_root = ".";
|
|
2427
|
+
} else if (shape === "subdir") {
|
|
2428
|
+
const answer = await input2({
|
|
2429
|
+
message: "Directory holding the subject's documents:",
|
|
2430
|
+
default: "docs",
|
|
2431
|
+
validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
|
|
2432
|
+
});
|
|
2433
|
+
subject_root = answer.trim() || "docs";
|
|
2434
|
+
} else if (shape === "portfolio") {
|
|
2435
|
+
subject_root = null;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
let content_sensitivity;
|
|
2439
|
+
if (!options.profile) {
|
|
2440
|
+
console.log();
|
|
2441
|
+
content_sensitivity = await select2({
|
|
2442
|
+
message: "How should agents treat this repository's contents?",
|
|
2443
|
+
choices: [
|
|
2444
|
+
{ value: "standard", name: "Standard \u2014 agents may quote contents in journals and decisions" },
|
|
2445
|
+
{
|
|
2446
|
+
value: "sensitive",
|
|
2447
|
+
name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
|
|
2448
|
+
}
|
|
2449
|
+
],
|
|
2450
|
+
default: "standard"
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
let push_policy;
|
|
2454
|
+
if (!options.profile) {
|
|
2455
|
+
console.log();
|
|
2456
|
+
push_policy = await select2({
|
|
2457
|
+
message: "Should this repository be allowed to push to a remote?",
|
|
2458
|
+
choices: [
|
|
2459
|
+
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
2460
|
+
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
2461
|
+
{
|
|
2462
|
+
value: "blocked",
|
|
2463
|
+
name: "No \u2014 block pushes (a pre-push hook plus a disabled push URL)"
|
|
2464
|
+
}
|
|
2465
|
+
],
|
|
2466
|
+
default: "open"
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
2090
2469
|
const manifest = {
|
|
2091
2470
|
version: packageVersion,
|
|
2092
2471
|
profile,
|
|
2093
2472
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2094
2473
|
components: installedComponents,
|
|
2095
2474
|
...role ? { role } : {},
|
|
2096
|
-
...tracking_mode ? { tracking_mode, external_provider } : {}
|
|
2475
|
+
...tracking_mode ? { tracking_mode, external_provider } : {},
|
|
2476
|
+
...subject_root !== void 0 ? { subject_root } : {},
|
|
2477
|
+
...content_sensitivity ? { content_sensitivity } : {},
|
|
2478
|
+
...push_policy ? { push_policy } : {}
|
|
2097
2479
|
};
|
|
2098
2480
|
await writeManifest(targetDir, manifest);
|
|
2099
2481
|
printSuccess(`Initialized with profile "${profile}" \u2014 ${fileCount} files installed`);
|
|
2482
|
+
if (push_policy === "blocked" || push_policy === "guarded") {
|
|
2483
|
+
try {
|
|
2484
|
+
if (push_policy === "blocked") {
|
|
2485
|
+
const hook = await installPrePushHook(targetDir);
|
|
2486
|
+
if (hook.status === "refused-foreign-hooks-path") {
|
|
2487
|
+
printWarning(
|
|
2488
|
+
`Did not install the pre-push hook: core.hooksPath is already "${hook.existing}" (set ${hook.scope === "local" ? "for this repository" : "globally"}), so another hook manager owns it. Overwriting would silently disable those hooks. Chain an Arcane pre-push guard into that directory yourself, or unset core.hooksPath first.`
|
|
2489
|
+
);
|
|
2490
|
+
} else {
|
|
2491
|
+
printInfo("Installed a pre-push hook that blocks pushes from this repository.");
|
|
2492
|
+
}
|
|
2493
|
+
const urls = await disablePushUrls(targetDir);
|
|
2494
|
+
if (urls.length === 0) {
|
|
2495
|
+
printWarning(
|
|
2496
|
+
"No remote is configured, so only the pre-push hook is active. A remote added later will NOT have its push URL disabled automatically, and a `--no-verify` push to it would succeed. Run `spell doctor` after adding one \u2014 it reports this gap."
|
|
2497
|
+
);
|
|
2498
|
+
} else {
|
|
2499
|
+
printInfo(
|
|
2500
|
+
`Disabled the push URL for: ${urls.map((u) => u.remote).join(", ")} (fetch still works).`
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
printInfo("Run `spell unblock-push` from a terminal to undo this.");
|
|
2504
|
+
} else {
|
|
2505
|
+
printInfo(
|
|
2506
|
+
"Marked as push-guarded. No technical control was installed \u2014 check the remote is the one you intend before pushing. `spell doctor` will keep reminding you."
|
|
2507
|
+
);
|
|
2508
|
+
}
|
|
2509
|
+
} catch (err) {
|
|
2510
|
+
printWarning(
|
|
2511
|
+
`Could not fully apply push-safety controls: ${err instanceof Error ? err.message : String(err)}. The manifest records push_policy: "${push_policy}", but this repository may not actually be protected. Run \`spell doctor\` to see exactly what is and isn't in place.`
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2100
2515
|
let agentsConfigured = false;
|
|
2101
2516
|
if (hasAgents && !options.profile) {
|
|
2102
2517
|
console.log();
|
|
@@ -2142,7 +2557,7 @@ async function runInit(options, targetDir, assetsDir, packageVersion) {
|
|
|
2142
2557
|
}
|
|
2143
2558
|
|
|
2144
2559
|
// src/commands/add.ts
|
|
2145
|
-
import { join as
|
|
2560
|
+
import { join as join6 } from "path";
|
|
2146
2561
|
async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
2147
2562
|
let manifest;
|
|
2148
2563
|
try {
|
|
@@ -2177,7 +2592,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2177
2592
|
let fileCount = 0;
|
|
2178
2593
|
const installedFiles = [];
|
|
2179
2594
|
for (const file of component.files) {
|
|
2180
|
-
const srcPath =
|
|
2595
|
+
const srcPath = join6(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2181
2596
|
if (options.dryRun) {
|
|
2182
2597
|
console.log(` [dry-run] Would copy: ${file}`);
|
|
2183
2598
|
} else {
|
|
@@ -2187,7 +2602,7 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2187
2602
|
fileCount++;
|
|
2188
2603
|
}
|
|
2189
2604
|
for (const dir of component.directories ?? []) {
|
|
2190
|
-
const srcDirPath =
|
|
2605
|
+
const srcDirPath = join6(assetsDir, dir);
|
|
2191
2606
|
if (options.dryRun) {
|
|
2192
2607
|
console.log(` [dry-run] Would copy directory: ${dir}/`);
|
|
2193
2608
|
} else {
|
|
@@ -2217,12 +2632,12 @@ async function runAdd(name, options, targetDir, assetsDir, packageVersion) {
|
|
|
2217
2632
|
}
|
|
2218
2633
|
|
|
2219
2634
|
// src/commands/update.ts
|
|
2220
|
-
import { join as
|
|
2635
|
+
import { join as join8 } from "path";
|
|
2221
2636
|
|
|
2222
2637
|
// src/modules/hub.ts
|
|
2223
|
-
import { confirm as confirm2, select as select3 } from "@inquirer/prompts";
|
|
2224
|
-
import { readdir as readdir3, writeFile as
|
|
2225
|
-
import { join as
|
|
2638
|
+
import { confirm as confirm2, select as select3, input as input3 } from "@inquirer/prompts";
|
|
2639
|
+
import { readdir as readdir3, writeFile as writeFile6 } from "fs/promises";
|
|
2640
|
+
import { join as join7 } from "path";
|
|
2226
2641
|
var MANIFEST_RETROFITS = [
|
|
2227
2642
|
{
|
|
2228
2643
|
field: "role",
|
|
@@ -2241,7 +2656,7 @@ var MANIFEST_RETROFITS = [
|
|
|
2241
2656
|
// Mirrors init.ts's Step 5b branching exactly (EF-14 D5): docs-only
|
|
2242
2657
|
// profiles get a silent default, full/lite get asked.
|
|
2243
2658
|
ask: async (manifest) => {
|
|
2244
|
-
if (manifest.profile === "governance-only" || manifest.profile === "methodology") {
|
|
2659
|
+
if (manifest.profile === "governance-only" || manifest.profile === "methodology" || manifest.profile === "docs") {
|
|
2245
2660
|
return { tracking_mode: "internal", external_provider: null };
|
|
2246
2661
|
}
|
|
2247
2662
|
const tracking_mode = await select3({
|
|
@@ -2264,6 +2679,85 @@ var MANIFEST_RETROFITS = [
|
|
|
2264
2679
|
}
|
|
2265
2680
|
return { tracking_mode, external_provider: null };
|
|
2266
2681
|
}
|
|
2682
|
+
},
|
|
2683
|
+
{
|
|
2684
|
+
field: "content_sensitivity",
|
|
2685
|
+
needsRetrofit: (m) => m.content_sensitivity === void 0,
|
|
2686
|
+
// EF-12. Asked for every profile -- a code repo can hold sensitive records
|
|
2687
|
+
// too -- and defaults to "standard", so an operator who just presses enter
|
|
2688
|
+
// keeps today's behaviour exactly.
|
|
2689
|
+
ask: async () => {
|
|
2690
|
+
const content_sensitivity = await select3({
|
|
2691
|
+
message: "How should agents treat this repository's contents?",
|
|
2692
|
+
choices: [
|
|
2693
|
+
{
|
|
2694
|
+
value: "standard",
|
|
2695
|
+
name: "Standard \u2014 agents may quote contents in journals and decisions"
|
|
2696
|
+
},
|
|
2697
|
+
{
|
|
2698
|
+
value: "sensitive",
|
|
2699
|
+
name: "Sensitive \u2014 agents reference documents by path, never transcribe them"
|
|
2700
|
+
}
|
|
2701
|
+
],
|
|
2702
|
+
default: "standard"
|
|
2703
|
+
});
|
|
2704
|
+
return { content_sensitivity };
|
|
2705
|
+
}
|
|
2706
|
+
},
|
|
2707
|
+
{
|
|
2708
|
+
field: "subject_root",
|
|
2709
|
+
// EF-07. Only the docs profile is asked: other profiles describe code or a
|
|
2710
|
+
// venture portfolio, where "what is this repo about" is already answered.
|
|
2711
|
+
// A docs install that legitimately holds several subjects answers
|
|
2712
|
+
// "portfolio" and stays unset -- so this retrofit deliberately does NOT
|
|
2713
|
+
// re-ask on every update. It is gated on the field being absent AND the
|
|
2714
|
+
// profile being docs, and once answered (either way) it never fires again,
|
|
2715
|
+
// because "portfolio" writes an explicit empty marker.
|
|
2716
|
+
needsRetrofit: (m) => m.profile === "docs" && m.subject_root === void 0,
|
|
2717
|
+
ask: async () => {
|
|
2718
|
+
const shape = await select3({
|
|
2719
|
+
message: "What does this repository hold?",
|
|
2720
|
+
choices: [
|
|
2721
|
+
{
|
|
2722
|
+
value: "root",
|
|
2723
|
+
name: "One subject, at the repository root (documents sit alongside Arcane's files)"
|
|
2724
|
+
},
|
|
2725
|
+
{ value: "subdir", name: "One subject, in its own directory" },
|
|
2726
|
+
{ value: "portfolio", name: "Several subjects or ventures" }
|
|
2727
|
+
]
|
|
2728
|
+
});
|
|
2729
|
+
if (shape === "root") return { subject_root: "." };
|
|
2730
|
+
if (shape === "subdir") {
|
|
2731
|
+
const answer = await input3({
|
|
2732
|
+
message: "Directory holding the subject's documents:",
|
|
2733
|
+
default: "docs",
|
|
2734
|
+
validate: (v) => isValidSubjectRoot(v.trim() || "docs") || "Must be a relative path inside the repository (no leading /, drive letter, or ..)."
|
|
2735
|
+
});
|
|
2736
|
+
return { subject_root: answer.trim() || "docs" };
|
|
2737
|
+
}
|
|
2738
|
+
return { subject_root: null };
|
|
2739
|
+
}
|
|
2740
|
+
},
|
|
2741
|
+
{
|
|
2742
|
+
field: "push_policy",
|
|
2743
|
+
needsRetrofit: (m) => m.push_policy === void 0,
|
|
2744
|
+
// EF-09. Defaults to "open", so an operator who just presses enter keeps
|
|
2745
|
+
// today's behaviour exactly. NOTE: this only records the choice -- unlike
|
|
2746
|
+
// init, the retrofit does not install the hook or disable the push URL,
|
|
2747
|
+
// because an update should not silently start blocking pushes in a repo
|
|
2748
|
+
// someone is mid-workflow in. doctor reports the gap instead.
|
|
2749
|
+
ask: async () => {
|
|
2750
|
+
const push_policy = await select3({
|
|
2751
|
+
message: "Should this repository be allowed to push to a remote?",
|
|
2752
|
+
choices: [
|
|
2753
|
+
{ value: "open", name: "Yes \u2014 normal repository" },
|
|
2754
|
+
{ value: "guarded", name: "Sensitive, but keep push working \u2014 remind me instead" },
|
|
2755
|
+
{ value: "blocked", name: "No \u2014 block pushes" }
|
|
2756
|
+
],
|
|
2757
|
+
default: "open"
|
|
2758
|
+
});
|
|
2759
|
+
return { push_policy };
|
|
2760
|
+
}
|
|
2267
2761
|
}
|
|
2268
2762
|
];
|
|
2269
2763
|
async function runManifestRetrofits(manifest) {
|
|
@@ -2281,9 +2775,9 @@ async function runManifestRetrofits(manifest) {
|
|
|
2281
2775
|
return patch;
|
|
2282
2776
|
}
|
|
2283
2777
|
async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
2284
|
-
const registryPath =
|
|
2778
|
+
const registryPath = join7(targetDir, businessRoot, "registry.json");
|
|
2285
2779
|
if (await fileExists(registryPath)) return;
|
|
2286
|
-
const businessRootPath =
|
|
2780
|
+
const businessRootPath = join7(targetDir, businessRoot);
|
|
2287
2781
|
let ventureDirs;
|
|
2288
2782
|
try {
|
|
2289
2783
|
const entries = await readdir3(businessRootPath, { withFileTypes: true });
|
|
@@ -2321,13 +2815,32 @@ async function offerRegistryScaffold(targetDir, businessRoot) {
|
|
|
2321
2815
|
updated: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
2322
2816
|
ventures
|
|
2323
2817
|
};
|
|
2324
|
-
await
|
|
2818
|
+
await writeFile6(registryPath, JSON.stringify(registry, null, 2) + "\n", "utf-8");
|
|
2325
2819
|
console.log(
|
|
2326
2820
|
` \u2713 Scaffolded ${businessRoot}/registry.json with ${Object.keys(ventures).length} venture(s).`
|
|
2327
2821
|
);
|
|
2328
2822
|
}
|
|
2329
2823
|
|
|
2330
2824
|
// src/commands/update.ts
|
|
2825
|
+
function migrateLegacyComponents(components) {
|
|
2826
|
+
const out = [];
|
|
2827
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2828
|
+
for (const entry of components) {
|
|
2829
|
+
const replacements = LEGACY_COMPONENT_MIGRATIONS[entry.name];
|
|
2830
|
+
if (!replacements) {
|
|
2831
|
+
if (seen.has(entry.name)) continue;
|
|
2832
|
+
seen.add(entry.name);
|
|
2833
|
+
out.push(entry);
|
|
2834
|
+
continue;
|
|
2835
|
+
}
|
|
2836
|
+
for (const name of replacements) {
|
|
2837
|
+
if (seen.has(name)) continue;
|
|
2838
|
+
seen.add(name);
|
|
2839
|
+
out.push({ name, files: [], installedVersion: entry.installedVersion });
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
return out;
|
|
2843
|
+
}
|
|
2331
2844
|
async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
2332
2845
|
let manifest;
|
|
2333
2846
|
try {
|
|
@@ -2380,7 +2893,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2380
2893
|
}
|
|
2381
2894
|
let fileCount = 0;
|
|
2382
2895
|
const updatedComponents = [];
|
|
2383
|
-
|
|
2896
|
+
const componentsToUpdate = migrateLegacyComponents(manifest.components);
|
|
2897
|
+
for (const installed of componentsToUpdate) {
|
|
2384
2898
|
let component;
|
|
2385
2899
|
try {
|
|
2386
2900
|
component = getComponent(installed.name);
|
|
@@ -2394,8 +2908,15 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2394
2908
|
}
|
|
2395
2909
|
const updatedFiles = [];
|
|
2396
2910
|
for (const file of component.files) {
|
|
2397
|
-
const srcPath =
|
|
2398
|
-
const
|
|
2911
|
+
const srcPath = join8(assetsDir, component.sourceOverrides?.[file] ?? file);
|
|
2912
|
+
const targetExists = await fileExists(join8(targetDir, file));
|
|
2913
|
+
const preserveExisting = Boolean(component.skipExisting) && targetExists;
|
|
2914
|
+
if (component.initOnly && !targetExists) {
|
|
2915
|
+
console.log(
|
|
2916
|
+
` ! Missing: ${file} \u2014 not added automatically, because doing so would change how Git treats existing files. Run "spell add ${component.name}" if you want it.`
|
|
2917
|
+
);
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2399
2920
|
if (preserveExisting) {
|
|
2400
2921
|
console.log(` ${options.dryRun ? "[dry-run] Would preserve" : "Preserved"}: ${file}`);
|
|
2401
2922
|
} else if (options.dryRun) {
|
|
@@ -2405,10 +2926,12 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2405
2926
|
await copyFile(srcPath, targetDir, file, { force: true });
|
|
2406
2927
|
fileCount++;
|
|
2407
2928
|
}
|
|
2408
|
-
|
|
2929
|
+
if (!preserveExisting || installed.files.includes(file)) {
|
|
2930
|
+
updatedFiles.push(file);
|
|
2931
|
+
}
|
|
2409
2932
|
}
|
|
2410
2933
|
for (const dir of component.directories ?? []) {
|
|
2411
|
-
const srcDirPath =
|
|
2934
|
+
const srcDirPath = join8(assetsDir, dir);
|
|
2412
2935
|
if (options.dryRun) {
|
|
2413
2936
|
console.log(` [dry-run] Would update directory: ${dir}/`);
|
|
2414
2937
|
} else {
|
|
@@ -2436,7 +2959,16 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2436
2959
|
}
|
|
2437
2960
|
return;
|
|
2438
2961
|
}
|
|
2439
|
-
const
|
|
2962
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
2963
|
+
const retrofitPatch = interactive ? await runManifestRetrofits(manifest) : {};
|
|
2964
|
+
if (!interactive) {
|
|
2965
|
+
const pending = MANIFEST_RETROFITS.filter((r) => r.needsRetrofit(manifest));
|
|
2966
|
+
if (pending.length > 0) {
|
|
2967
|
+
console.log(
|
|
2968
|
+
` ! 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 to answer.`
|
|
2969
|
+
);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2440
2972
|
const updated = {
|
|
2441
2973
|
...manifest,
|
|
2442
2974
|
version: packageVersion,
|
|
@@ -2454,8 +2986,8 @@ async function runUpdate(options, targetDir, assetsDir, packageVersion) {
|
|
|
2454
2986
|
}
|
|
2455
2987
|
|
|
2456
2988
|
// src/modules/version-check.ts
|
|
2457
|
-
import { readFile as
|
|
2458
|
-
import { dirname, join as
|
|
2989
|
+
import { readFile as readFile5, writeFile as writeFile7, mkdir as mkdir5 } from "fs/promises";
|
|
2990
|
+
import { dirname, join as join9 } from "path";
|
|
2459
2991
|
import { homedir as homedir2 } from "os";
|
|
2460
2992
|
import { createRequire } from "module";
|
|
2461
2993
|
var require2 = createRequire(import.meta.url);
|
|
@@ -2469,11 +3001,11 @@ function getFeedUrl() {
|
|
|
2469
3001
|
}
|
|
2470
3002
|
return url;
|
|
2471
3003
|
}
|
|
2472
|
-
var DEFAULT_CACHE_PATH =
|
|
3004
|
+
var DEFAULT_CACHE_PATH = join9(homedir2(), ".arcane", "version-cache.json");
|
|
2473
3005
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2474
3006
|
async function readCache(cachePath) {
|
|
2475
3007
|
try {
|
|
2476
|
-
const content = await
|
|
3008
|
+
const content = await readFile5(cachePath, "utf-8");
|
|
2477
3009
|
const parsed = JSON.parse(content);
|
|
2478
3010
|
if (typeof parsed.latest !== "string" || typeof parsed.checkedAt !== "string") {
|
|
2479
3011
|
return null;
|
|
@@ -2484,9 +3016,9 @@ async function readCache(cachePath) {
|
|
|
2484
3016
|
}
|
|
2485
3017
|
}
|
|
2486
3018
|
async function writeCache(cachePath, latest) {
|
|
2487
|
-
await
|
|
3019
|
+
await mkdir5(dirname(cachePath), { recursive: true });
|
|
2488
3020
|
const entry = { latest, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2489
|
-
await
|
|
3021
|
+
await writeFile7(cachePath, JSON.stringify(entry, null, 2), "utf-8");
|
|
2490
3022
|
}
|
|
2491
3023
|
async function getLatestVersion(feedUrl) {
|
|
2492
3024
|
const normalizedBase = feedUrl.endsWith("/") ? feedUrl : feedUrl + "/";
|
|
@@ -2599,7 +3131,7 @@ async function runStatus(targetDir, packageVersion) {
|
|
|
2599
3131
|
|
|
2600
3132
|
// src/commands/uninstall.ts
|
|
2601
3133
|
import { rm as rm2 } from "fs/promises";
|
|
2602
|
-
import { join as
|
|
3134
|
+
import { join as join10 } from "path";
|
|
2603
3135
|
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2604
3136
|
async function runUninstall(options, targetDir) {
|
|
2605
3137
|
let manifest;
|
|
@@ -2642,7 +3174,7 @@ async function runUninstall(options, targetDir) {
|
|
|
2642
3174
|
let removed = 0;
|
|
2643
3175
|
for (const component of manifest.components) {
|
|
2644
3176
|
for (const file of component.files) {
|
|
2645
|
-
const filePath =
|
|
3177
|
+
const filePath = join10(targetDir, file);
|
|
2646
3178
|
try {
|
|
2647
3179
|
await rm2(filePath, { force: true });
|
|
2648
3180
|
removed++;
|
|
@@ -2650,16 +3182,16 @@ async function runUninstall(options, targetDir) {
|
|
|
2650
3182
|
}
|
|
2651
3183
|
}
|
|
2652
3184
|
}
|
|
2653
|
-
await rm2(
|
|
3185
|
+
await rm2(join10(targetDir, ".arcane.json"), { force: true });
|
|
2654
3186
|
const agentDirs = [
|
|
2655
3187
|
".github/agents",
|
|
2656
3188
|
".arcane/agents",
|
|
2657
3189
|
".arcane/generated"
|
|
2658
3190
|
];
|
|
2659
3191
|
for (const dir of agentDirs) {
|
|
2660
|
-
await rm2(
|
|
3192
|
+
await rm2(join10(targetDir, dir), { recursive: true, force: true });
|
|
2661
3193
|
}
|
|
2662
|
-
await rm2(
|
|
3194
|
+
await rm2(join10(targetDir, ".arcane/agents.yaml"), { force: true });
|
|
2663
3195
|
const markerFiles = [
|
|
2664
3196
|
"CLAUDE.md",
|
|
2665
3197
|
"AGENTS.md",
|
|
@@ -2673,8 +3205,8 @@ async function runUninstall(options, targetDir) {
|
|
|
2673
3205
|
}
|
|
2674
3206
|
|
|
2675
3207
|
// src/commands/doctor.ts
|
|
2676
|
-
import { access as access3, readFile as
|
|
2677
|
-
import { join as
|
|
3208
|
+
import { access as access3, readFile as readFile6, mkdir as mkdir6, copyFile as fsCopyFile2 } from "fs/promises";
|
|
3209
|
+
import { join as join11, dirname as dirname2 } from "path";
|
|
2678
3210
|
import { execFile as execFile2 } from "child_process";
|
|
2679
3211
|
import { promisify } from "util";
|
|
2680
3212
|
|
|
@@ -2762,8 +3294,8 @@ async function checkVSCodeExtension(id, label) {
|
|
|
2762
3294
|
const name = `VS Code extension: ${label}`;
|
|
2763
3295
|
const home = process.env["HOME"] ?? "";
|
|
2764
3296
|
const extDirs = [
|
|
2765
|
-
|
|
2766
|
-
|
|
3297
|
+
join11(home, ".vscode", "extensions"),
|
|
3298
|
+
join11(home, ".vscode-insiders", "extensions")
|
|
2767
3299
|
];
|
|
2768
3300
|
for (const dir of extDirs) {
|
|
2769
3301
|
try {
|
|
@@ -2797,13 +3329,13 @@ async function checkVSCodeExtension(id, label) {
|
|
|
2797
3329
|
}
|
|
2798
3330
|
async function checkArcaneManifest(targetDir) {
|
|
2799
3331
|
const name = "Arcane manifest (.arcane.json)";
|
|
2800
|
-
const manifestPath2 =
|
|
3332
|
+
const manifestPath2 = join11(targetDir, ".arcane.json");
|
|
2801
3333
|
try {
|
|
2802
3334
|
await access3(manifestPath2);
|
|
2803
3335
|
} catch {
|
|
2804
|
-
const selfHostedManifestPath =
|
|
3336
|
+
const selfHostedManifestPath = join11(targetDir, "src", "assets", ".arcane.json");
|
|
2805
3337
|
try {
|
|
2806
|
-
const raw = await
|
|
3338
|
+
const raw = await readFile6(selfHostedManifestPath, "utf8");
|
|
2807
3339
|
const manifest = JSON.parse(raw);
|
|
2808
3340
|
if (manifest.selfHosted === true && manifest.tracking_mode === "internal") {
|
|
2809
3341
|
return {
|
|
@@ -2822,7 +3354,7 @@ async function checkArcaneManifest(targetDir) {
|
|
|
2822
3354
|
};
|
|
2823
3355
|
}
|
|
2824
3356
|
try {
|
|
2825
|
-
const raw = await
|
|
3357
|
+
const raw = await readFile6(manifestPath2, "utf8");
|
|
2826
3358
|
const manifest = JSON.parse(raw);
|
|
2827
3359
|
if (!manifest.version || !Array.isArray(manifest.components)) {
|
|
2828
3360
|
return {
|
|
@@ -2893,7 +3425,7 @@ async function checkSessionContinuity(targetDir) {
|
|
|
2893
3425
|
const results = [];
|
|
2894
3426
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
2895
3427
|
const name = `Session file: ${file}`;
|
|
2896
|
-
const filePath =
|
|
3428
|
+
const filePath = join11(targetDir, file);
|
|
2897
3429
|
try {
|
|
2898
3430
|
await access3(filePath);
|
|
2899
3431
|
results.push({ name, passed: true, message: "present" });
|
|
@@ -2911,18 +3443,69 @@ async function checkSessionContinuity(targetDir) {
|
|
|
2911
3443
|
async function fixSessionContinuity(targetDir, assetsDir) {
|
|
2912
3444
|
const created = [];
|
|
2913
3445
|
for (const file of SESSION_CONTINUITY_FILES) {
|
|
2914
|
-
const destPath =
|
|
3446
|
+
const destPath = join11(targetDir, file);
|
|
2915
3447
|
try {
|
|
2916
3448
|
await access3(destPath);
|
|
2917
3449
|
} catch {
|
|
2918
|
-
const srcPath =
|
|
2919
|
-
await
|
|
3450
|
+
const srcPath = join11(assetsDir, file);
|
|
3451
|
+
await mkdir6(dirname2(destPath), { recursive: true });
|
|
2920
3452
|
await fsCopyFile2(srcPath, destPath);
|
|
2921
3453
|
created.push(file);
|
|
2922
3454
|
}
|
|
2923
3455
|
}
|
|
2924
3456
|
return created;
|
|
2925
3457
|
}
|
|
3458
|
+
async function checkPushPolicy(targetDir) {
|
|
3459
|
+
const name = "Push policy (EF-09)";
|
|
3460
|
+
let manifest;
|
|
3461
|
+
try {
|
|
3462
|
+
manifest = await readManifest(targetDir);
|
|
3463
|
+
} catch {
|
|
3464
|
+
return { name, passed: true, message: "not initialized \u2014 nothing to check" };
|
|
3465
|
+
}
|
|
3466
|
+
const policy = manifest.push_policy ?? "open";
|
|
3467
|
+
if (policy === "open") {
|
|
3468
|
+
return { name, passed: true, message: "open \u2014 pushes allowed" };
|
|
3469
|
+
}
|
|
3470
|
+
if (policy === "guarded") {
|
|
3471
|
+
const remotes2 = await listRemotes(targetDir);
|
|
3472
|
+
const where = remotes2.length === 0 ? "none configured" : remotes2.join(", ");
|
|
3473
|
+
return {
|
|
3474
|
+
name,
|
|
3475
|
+
passed: false,
|
|
3476
|
+
blocking: false,
|
|
3477
|
+
message: `guarded \u2014 no technical control installed. Push targets: ${where}. Confirm those are the remotes you intend before pushing.`
|
|
3478
|
+
};
|
|
3479
|
+
}
|
|
3480
|
+
const hookActive = await isHookEnforced(targetDir);
|
|
3481
|
+
const remotes = await listRemotes(targetDir);
|
|
3482
|
+
const open = await undisabledRemotes(targetDir);
|
|
3483
|
+
if (hookActive && open.length === 0) {
|
|
3484
|
+
if (remotes.length === 0) {
|
|
3485
|
+
return {
|
|
3486
|
+
name,
|
|
3487
|
+
passed: false,
|
|
3488
|
+
blocking: false,
|
|
3489
|
+
message: "blocked \u2014 pre-push hook in place, but no remote is configured, so no push URL is disabled. If a remote is added later, run `spell doctor` again: a --no-verify push to it would succeed until the URL is disabled too."
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
return {
|
|
3493
|
+
name,
|
|
3494
|
+
passed: true,
|
|
3495
|
+
message: `blocked \u2014 pre-push hook in place and push URL disabled for ${remotes.join(", ")}`
|
|
3496
|
+
};
|
|
3497
|
+
}
|
|
3498
|
+
const missing = [
|
|
3499
|
+
hookActive ? null : "pre-push hook (config or hook file absent)",
|
|
3500
|
+
open.length > 0 ? `push URL still live for: ${open.join(", ")}` : null
|
|
3501
|
+
].filter(Boolean);
|
|
3502
|
+
return {
|
|
3503
|
+
name,
|
|
3504
|
+
passed: false,
|
|
3505
|
+
blocking: false,
|
|
3506
|
+
message: `declared "blocked" but not enforced \u2014 ${missing.join("; ")}. The manifest claims a protection this repository does not have.`
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
2926
3509
|
async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
2927
3510
|
console.log("\nspell doctor \u2014 checking your Arcane environment\n");
|
|
2928
3511
|
const results = await Promise.all([
|
|
@@ -2930,7 +3513,8 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
2930
3513
|
checkVSCodeExtension("GitHub.copilot-chat", "GitHub Copilot (Chat)"),
|
|
2931
3514
|
checkArcaneManifest(targetDir),
|
|
2932
3515
|
Promise.resolve(checkIncidentReleaseGate()),
|
|
2933
|
-
checkPullRebase(targetDir)
|
|
3516
|
+
checkPullRebase(targetDir),
|
|
3517
|
+
checkPushPolicy(targetDir)
|
|
2934
3518
|
]);
|
|
2935
3519
|
const sessionResults = await checkSessionContinuity(targetDir);
|
|
2936
3520
|
results.push(...sessionResults);
|
|
@@ -2974,17 +3558,83 @@ async function runDoctor(targetDir, options = {}, assetsDir) {
|
|
|
2974
3558
|
}
|
|
2975
3559
|
}
|
|
2976
3560
|
|
|
3561
|
+
// src/commands/unblock-push.ts
|
|
3562
|
+
import { basename, resolve } from "path";
|
|
3563
|
+
import { input as input4 } from "@inquirer/prompts";
|
|
3564
|
+
async function runUnblockPush(targetDir) {
|
|
3565
|
+
let manifest;
|
|
3566
|
+
try {
|
|
3567
|
+
manifest = await readManifest(targetDir);
|
|
3568
|
+
} catch (err) {
|
|
3569
|
+
if (err instanceof ManifestNotFoundError) {
|
|
3570
|
+
console.error('Not initialized. Run "spell init" first.');
|
|
3571
|
+
process.exit(1);
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
throw err;
|
|
3575
|
+
}
|
|
3576
|
+
const policy = manifest.push_policy ?? "open";
|
|
3577
|
+
if (policy === "open") {
|
|
3578
|
+
printInfo("This repository is not push-blocked \u2014 nothing to undo.");
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
if (!process.stdin.isTTY) {
|
|
3582
|
+
console.error(
|
|
3583
|
+
"spell unblock-push must be run from an interactive terminal.\nLifting a push block is a deliberate decision, so it cannot be scripted, piped, or run in CI."
|
|
3584
|
+
);
|
|
3585
|
+
process.exit(1);
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
const repoName = basename(resolve(targetDir));
|
|
3589
|
+
if (repoName === "") {
|
|
3590
|
+
console.error(
|
|
3591
|
+
"Could not determine a repository name to confirm against (is this repository at a filesystem root?). Refusing to unblock."
|
|
3592
|
+
);
|
|
3593
|
+
process.exit(1);
|
|
3594
|
+
return;
|
|
3595
|
+
}
|
|
3596
|
+
printWarning(`This repository's push_policy is "${policy}".`);
|
|
3597
|
+
printInfo(
|
|
3598
|
+
"Unblocking restores the ability to push this repository's full history to its remote. Deleting a file later does not undo a push: the content stays in history, in any clone already made, and in the remote's own backups."
|
|
3599
|
+
);
|
|
3600
|
+
console.log();
|
|
3601
|
+
const typed = await input4({
|
|
3602
|
+
message: `Type the repository name to confirm (${repoName}):`
|
|
3603
|
+
});
|
|
3604
|
+
if (typed.trim() !== repoName) {
|
|
3605
|
+
console.error(`
|
|
3606
|
+
Name did not match ("${typed.trim()}" \u2260 "${repoName}"). Nothing changed.`);
|
|
3607
|
+
process.exit(1);
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
await removePrePushHook(targetDir);
|
|
3611
|
+
const restored = await restorePushUrls(targetDir);
|
|
3612
|
+
const updated = {
|
|
3613
|
+
...manifest,
|
|
3614
|
+
push_policy: "open",
|
|
3615
|
+
push_policy_unblocked_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3616
|
+
};
|
|
3617
|
+
await writeManifest(targetDir, updated);
|
|
3618
|
+
printSuccess(`Push unblocked for "${repoName}".`);
|
|
3619
|
+
if (restored.length > 0) {
|
|
3620
|
+
printInfo(`Restored push URL for: ${restored.join(", ")}.`);
|
|
3621
|
+
}
|
|
3622
|
+
printInfo(
|
|
3623
|
+
"Recorded in .arcane.json as push_policy: open, with the time it was lifted. Commit that change so the repository's actual posture stays visible to everyone."
|
|
3624
|
+
);
|
|
3625
|
+
}
|
|
3626
|
+
|
|
2977
3627
|
// src/index.ts
|
|
2978
3628
|
import { realpathSync } from "fs";
|
|
2979
3629
|
var require3 = createRequire2(import.meta.url);
|
|
2980
3630
|
var pkg = require3("../package.json");
|
|
2981
|
-
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ??
|
|
3631
|
+
var ASSETS_DIR = process.env["ARCANE_ASSETS_DIR"] ?? join12(dirname3(fileURLToPath(import.meta.url)), "assets");
|
|
2982
3632
|
var program = new Command();
|
|
2983
3633
|
program.name("spell").description("Arcane framework CLI \u2014 scaffold and manage governance files").version(pkg.version).action(async () => {
|
|
2984
3634
|
await printWelcome(pkg.version, process.cwd());
|
|
2985
3635
|
program.outputHelp();
|
|
2986
3636
|
});
|
|
2987
|
-
program.command("init").description("Scaffold Arcane framework files into the current repository").option("--profile <profile>", "Profile: full | lite | methodology | governance-only").option("--force", "Overwrite existing files without error").option("--dry-run", "Preview what would be installed without making changes").action(
|
|
3637
|
+
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").action(
|
|
2988
3638
|
async (opts) => {
|
|
2989
3639
|
await runInit(
|
|
2990
3640
|
{
|
|
@@ -3023,6 +3673,9 @@ program.command("status").description("Show installed components and check for u
|
|
|
3023
3673
|
program.command("uninstall").description("Remove all installed Arcane framework files").option("--yes", "Skip confirmation prompt").option("--dry-run", "Preview what would be removed without deleting files").action(async (opts) => {
|
|
3024
3674
|
await runUninstall({ yes: opts.yes, dryRun: opts.dryRun }, process.cwd());
|
|
3025
3675
|
});
|
|
3676
|
+
program.command("unblock-push").description("Lift this repository's push block (interactive terminal only)").action(async () => {
|
|
3677
|
+
await runUnblockPush(process.cwd());
|
|
3678
|
+
});
|
|
3026
3679
|
program.command("doctor").description("Check your environment for Arcane prerequisites").option("--fix", "Automatically create missing session continuity files").action(async (opts) => {
|
|
3027
3680
|
await runDoctor(process.cwd(), { fix: opts.fix }, ASSETS_DIR);
|
|
3028
3681
|
});
|