@noir-ai/create 1.14.0-beta.1 → 1.15.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +226 -46
- package/dist/index.js +604 -224
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/templates/config.env.tmpl +29 -66
- package/templates/env.example.tmpl +73 -47
- package/templates/noir-readme.md.tmpl +1 -1
package/dist/index.js
CHANGED
|
@@ -63,6 +63,10 @@ var MANIFEST_PATH_PARITY = [
|
|
|
63
63
|
[P.noirMd, paths.noirMd],
|
|
64
64
|
[P.rulesMd, paths.rulesMd]
|
|
65
65
|
];
|
|
66
|
+
var REFRESHABLE_SEED_KIND = {
|
|
67
|
+
[P.envExample]: "envExample",
|
|
68
|
+
[P.rulesMd]: "rulesSeed"
|
|
69
|
+
};
|
|
66
70
|
function buildManifest(ctx) {
|
|
67
71
|
return [...hostAgnosticEntries(ctx), ...buildHostArtifacts(resolveAdapter(ctx.host), ctx)];
|
|
68
72
|
}
|
|
@@ -82,13 +86,17 @@ function hostAgnosticEntries(ctx) {
|
|
|
82
86
|
description: "user config seed (host + mode)"
|
|
83
87
|
},
|
|
84
88
|
{
|
|
89
|
+
// Refreshable: the variable set Noir documents grows between releases,
|
|
90
|
+
// and this file is pure documentation the user is never asked to edit —
|
|
91
|
+
// so an upgrade may replace an untouched older copy of it.
|
|
85
92
|
path: P.envExample,
|
|
86
93
|
mode: "skipIfExists",
|
|
87
94
|
template: "env.example.tmpl",
|
|
95
|
+
refreshIfStale: true,
|
|
88
96
|
description: ".noir/.env.example committable documentation (never loaded)"
|
|
89
97
|
},
|
|
90
98
|
{
|
|
91
|
-
//
|
|
99
|
+
// init creates the REAL env file, not just the example. The body is
|
|
92
100
|
// all-comment, so the file parses to an EMPTY overlay and creating it
|
|
93
101
|
// changes no behaviour — it exists so the user never has to copy the
|
|
94
102
|
// example by hand. `fileMode: 0o600` because the file holds tokens the
|
|
@@ -108,13 +116,16 @@ function hostAgnosticEntries(ctx) {
|
|
|
108
116
|
description: "NOIR.md auto-brief (project id pointer)"
|
|
109
117
|
},
|
|
110
118
|
{
|
|
119
|
+
// Refreshable: shipped as a starting contract the user is INVITED to
|
|
120
|
+
// rewrite, so only the never-edited copy may be swapped for a newer one.
|
|
111
121
|
path: P.rulesMd,
|
|
112
122
|
mode: "skipIfExists",
|
|
113
123
|
template: "rules-seed.md.tmpl",
|
|
124
|
+
refreshIfStale: true,
|
|
114
125
|
description: "AI working-rules seed"
|
|
115
126
|
},
|
|
116
127
|
{
|
|
117
|
-
//
|
|
128
|
+
// The `.noir/` runtime map — what init just wrote,
|
|
118
129
|
// what appears later (and which command creates it), and where to go
|
|
119
130
|
// next. Host-agnostic: it describes the canonical store, which every
|
|
120
131
|
// host shares. Co-owned (README_BLOCK) so user notes survive while the
|
|
@@ -289,7 +300,7 @@ function buildHostArtifacts(adapter, ctx) {
|
|
|
289
300
|
return entries;
|
|
290
301
|
}
|
|
291
302
|
var SESSION_START_HOOK_SCRIPT = `#!/usr/bin/env node
|
|
292
|
-
// Noir
|
|
303
|
+
// Noir SessionStart hook \u2014 injects the skill router contract at session start.
|
|
293
304
|
// The mutable contract lives in .noir/router.md (a Noir managed block, so user
|
|
294
305
|
// edits outside the markers survive noir sync). This script is a pure runner.
|
|
295
306
|
import { readFileSync, existsSync } from 'node:fs';
|
|
@@ -470,176 +481,292 @@ function mergeThreeWay(base, ours, theirs, style = "minimal") {
|
|
|
470
481
|
}
|
|
471
482
|
|
|
472
483
|
// src/migrations/index.ts
|
|
473
|
-
import { existsSync as
|
|
474
|
-
import { join as
|
|
484
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
485
|
+
import { join as join5 } from "path";
|
|
475
486
|
|
|
476
|
-
// src/
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
res = script.run(ctx);
|
|
487
|
-
} catch (err) {
|
|
488
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
489
|
-
aggregate.conflicts.push(`<runner>:${script.from}\u2192${script.to} threw: ${msg}`);
|
|
490
|
-
continue;
|
|
491
|
-
}
|
|
492
|
-
aggregate.changed.push(...res.changed);
|
|
493
|
-
aggregate.conflicts.push(...res.conflicts);
|
|
494
|
-
aggregate.notes.push(...res.notes);
|
|
495
|
-
}
|
|
496
|
-
return {
|
|
497
|
-
...aggregate,
|
|
498
|
-
from,
|
|
499
|
-
to,
|
|
500
|
-
ran
|
|
501
|
-
};
|
|
502
|
-
}
|
|
503
|
-
function pickWindow(from, to, scripts) {
|
|
504
|
-
return scripts.filter((s) => compareVer(s.from) >= compareVer(from) && compareVer(s.to) <= compareVer(to)).sort((a, b) => compareVer(a.to) - compareVer(b.to));
|
|
505
|
-
}
|
|
506
|
-
function compareVer(v) {
|
|
507
|
-
const parts = v.split("-")[0]?.split(".") ?? [];
|
|
508
|
-
let n = 0;
|
|
509
|
-
for (let i = 0; i < 3; i++) {
|
|
510
|
-
const p = Number(parts[i] ?? "0");
|
|
511
|
-
n = n * 1e3 + (Number.isFinite(p) ? p : 0);
|
|
512
|
-
}
|
|
513
|
-
return n;
|
|
487
|
+
// src/template.ts
|
|
488
|
+
var TOKEN = /\{\{\s*([^}{}]+?)\s*\}\}/g;
|
|
489
|
+
function render(template, vars) {
|
|
490
|
+
return template.replace(TOKEN, (whole, name) => {
|
|
491
|
+
if (!Object.hasOwn(vars, name)) return whole;
|
|
492
|
+
const v = vars[name];
|
|
493
|
+
if (v === void 0 || v === null) return whole;
|
|
494
|
+
return String(v);
|
|
495
|
+
});
|
|
514
496
|
}
|
|
515
497
|
|
|
516
|
-
// src/
|
|
517
|
-
var
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
function appendSeparated(prev, block) {
|
|
546
|
-
if (prev.length === 0) return block;
|
|
547
|
-
if (prev.endsWith("\n\n")) return `${prev}${block}`;
|
|
548
|
-
return prev.endsWith("\n") ? `${prev}
|
|
549
|
-
${block}` : `${prev}
|
|
498
|
+
// src/template-history.ts
|
|
499
|
+
var ENV_EXAMPLE_1_1_0 = `# .noir/.env.example \u2014 committable documentation of the format.
|
|
500
|
+
#
|
|
501
|
+
# This file is a TEMPLATE and is never loaded. Noir reads \`.noir/.env\`, which
|
|
502
|
+
# \`noir init\` already created for you at mode 0600 \u2014 edit that file and
|
|
503
|
+
# uncomment the entries you need. Keep this example in git so the variable set
|
|
504
|
+
# stays discoverable.
|
|
505
|
+
#
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
# The block below is the same header \`.noir/.env\` carries (spec 12.1).
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
#
|
|
510
|
+
# .noir/.env \u2014 project-scoped configuration for this repo.
|
|
511
|
+
#
|
|
512
|
+
# This file is the recommended home for project-scoped configuration and
|
|
513
|
+
# secrets. Precedence:
|
|
514
|
+
# 1. run profile env run.profiles.<n>.env (per-invocation; merges OVER)
|
|
515
|
+
# 2. .noir/.env <- recommended home for project-scoped configuration
|
|
516
|
+
# 3. real environment CI / container / launchd / shell rc
|
|
517
|
+
# 4. built-in default
|
|
518
|
+
#
|
|
519
|
+
# A real environment variable is a FALLBACK: it applies only to keys this file
|
|
520
|
+
# leaves unset. A machine-global export therefore cannot shadow a value here.
|
|
521
|
+
# Run \`noir env\` to see which source is winning for each key.
|
|
522
|
+
#
|
|
523
|
+
# Keep this file private: chmod 600 .noir/.env
|
|
524
|
+
# It is gitignored by Noir's managed .gitignore block, and Noir REFUSES to load
|
|
525
|
+
# it if it is tracked by git (a cloned repo could redirect credentials).
|
|
526
|
+
# Never commit \`.noir/.env\`. (THIS file is the committable one.)
|
|
550
527
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
528
|
+
# --- Integrations -----------------------------------------------------------
|
|
529
|
+
# ClickUp \u2014 only if the noir-clickup integration is enabled.
|
|
530
|
+
# CLICKUP_API_TOKEN=pk_00000000000000000000000000000000
|
|
531
|
+
#
|
|
532
|
+
# CLICKUP_TEAM_ID is NOT an env var \u2014 Noir never reads it. Workspace binding
|
|
533
|
+
# (team/list/space ids) is config.yml instead:
|
|
534
|
+
# integrations:
|
|
535
|
+
# clickup:
|
|
536
|
+
# teamId: "ABC123" # optional \u2014 only for custom task IDs (#ABC-123)
|
|
537
|
+
|
|
538
|
+
# --- Remote embedders (context.embedder.kind: remote) -----------------------
|
|
539
|
+
# The default local embedder (kind: local) needs no key.
|
|
540
|
+
# OPENAI_API_KEY=sk-000000000000000000000000000000000000000000000000
|
|
541
|
+
# VOYAGE_API_KEY=pa-0000000000000000000000000000000000000000000000
|
|
542
|
+
# COHERE_API_KEY=0000000000000000000000000000000000000000
|
|
543
|
+
#
|
|
544
|
+
# Ollama (kind: ollama) reads its base URL from config.yml
|
|
545
|
+
# (context.embedder.baseURL); this variable is the fallback:
|
|
546
|
+
# OLLAMA_BASE_URL=http://localhost:11434
|
|
547
|
+
|
|
548
|
+
# --- Model provider key -----------------------------------------------------
|
|
549
|
+
# There is NO fixed "model key" variable, and setting one is not enough on its
|
|
550
|
+
# own. \`apiKeyEnv\` is a NAME, not an interpolation: the config stores the
|
|
551
|
+
# variable NAME and Noir reads process.env[<that name>] at call time. Write the
|
|
552
|
+
# bare name \u2014 never \`\${...}\` \u2014 in .noir/config.yml:
|
|
553
|
+
#
|
|
554
|
+
# model:
|
|
555
|
+
# providers:
|
|
556
|
+
# anthropic:
|
|
557
|
+
# apiKeyEnv: ANTHROPIC_API_KEY # -> reads $ANTHROPIC_API_KEY
|
|
558
|
+
# tiers:
|
|
559
|
+
# default:
|
|
560
|
+
# provider: anthropic
|
|
561
|
+
#
|
|
562
|
+
# Only \`run.profiles.<name>.env\` interpolates \`\${VAR}\`. With no provider
|
|
563
|
+
# configured the model layer degrades to templates \u2014 Noir never makes a silent
|
|
564
|
+
# paid call. \`ANTHROPIC_API_KEY\` is the conventional example, not a variable
|
|
565
|
+
# Noir looks up on its own:
|
|
566
|
+
# ANTHROPIC_API_KEY=sk-ant-0000000000000000000000000000000000000000000000
|
|
567
|
+
|
|
568
|
+
# --- Run profile selection --------------------------------------------------
|
|
569
|
+
# Selects a \`run.profiles.<name>\` entry for \`noir run\`.
|
|
570
|
+
# Precedence: --profile flag > NOIR_PROFILE > run.defaultProfile > host default.
|
|
571
|
+
# NOIR_PROFILE=work
|
|
572
|
+
|
|
573
|
+
# --- Update kill-switches ---------------------------------------------------
|
|
574
|
+
# NOIR_DISABLE_UPDATE_CHECK=1 # suppress the background startup check only
|
|
575
|
+
# NOIR_DISABLE_UPDATES=1 # make \`noir update\` refuse (exit 2)
|
|
576
|
+
|
|
577
|
+
# See docs/reference/environment.md for every variable Noir reads.
|
|
578
|
+
`;
|
|
579
|
+
var ENV_EXAMPLE_1_2_0 = `# .noir/.env.example \u2014 the committable reference for .noir/.env.
|
|
580
|
+
#
|
|
581
|
+
# What .noir/.env is for
|
|
582
|
+
# ----------------------
|
|
583
|
+
# .noir/.env is this project's configuration-and-secrets file. \`noir init\`
|
|
584
|
+
# creates it at mode 0600 and gitignores it, and Noir loads it on every
|
|
585
|
+
# command. For every key it defines, it WINS over your shell environment, so it
|
|
586
|
+
# is the recommended home for project-scoped values and secrets.
|
|
587
|
+
#
|
|
588
|
+
# Precedence (highest wins):
|
|
589
|
+
# 1. run profile env run.profiles.<n>.env (per-invocation; merges over)
|
|
590
|
+
# 2. .noir/.env the project-scoped file this example documents
|
|
591
|
+
# 3. real environment CI / container / launchd / shell rc
|
|
592
|
+
# 4. built-in default
|
|
593
|
+
#
|
|
594
|
+
# A real environment variable is only a FALLBACK for keys .noir/.env leaves
|
|
595
|
+
# unset. For a value scoped to one invocation, add a \`run.profiles.<name>.env\`
|
|
596
|
+
# entry and reference it there as \`\${VAR}\`, so the literal value stays out of
|
|
597
|
+
# config.yml; values that must not be committed belong in \`.noir/.env\`
|
|
598
|
+
# (gitignored) or the real environment. \`noir env\` reports the winning source
|
|
599
|
+
# for each key.
|
|
600
|
+
#
|
|
601
|
+
# What to commit
|
|
602
|
+
# --------------
|
|
603
|
+
# Keep .noir/.env OUT of git: it holds real secrets, Noir's managed .gitignore
|
|
604
|
+
# block excludes it, and Noir refuses to load a copy that IS tracked (a cloned
|
|
605
|
+
# repo could redirect credentials). chmod 600 .noir/.env to keep it private.
|
|
606
|
+
# Never commit the real file \u2014 commit THIS example, so the keys stay
|
|
607
|
+
# discoverable and documented.
|
|
608
|
+
#
|
|
609
|
+
# Every line below is documentation. Copy a key to .noir/.env and set a real
|
|
610
|
+
# value to enable it.
|
|
611
|
+
#
|
|
612
|
+
# Full reference: docs/reference/environment.md
|
|
613
|
+
|
|
614
|
+
# --- Host gateway (Claude Code via Z.AI / LiteLLM / OpenRouter / Kimi ...) ---
|
|
615
|
+
# Point the host at an Anthropic-shaped endpoint instead of api.anthropic.com.
|
|
616
|
+
#
|
|
617
|
+
# ANTHROPIC_BASE_URL \u2014 the gateway's base URL. Host ONLY: give the origin (for
|
|
618
|
+
# example https://gateway.example) and NOT the /v1/messages path \u2014 the host
|
|
619
|
+
# appends that itself.
|
|
620
|
+
# ANTHROPIC_AUTH_TOKEN \u2014 sent as \`Authorization: Bearer <token>\`. It is the
|
|
621
|
+
# HEADER variant of a credential: AUTH_TOKEN becomes Bearer, while
|
|
622
|
+
# ANTHROPIC_API_KEY becomes x-api-key. Do not set BOTH \u2014 that is an auth
|
|
623
|
+
# conflict.
|
|
624
|
+
# ANTHROPIC_DEFAULT_HAIKU_MODEL / _SONNET_MODEL / _OPUS_MODEL \u2014 remap the
|
|
625
|
+
# haiku / sonnet / opus aliases to the model ids your gateway serves.
|
|
626
|
+
# API_TIMEOUT_MS \u2014 per-request timeout in milliseconds (Claude Code default is
|
|
627
|
+
# 600000).
|
|
628
|
+
# CLAUDE_CODE_AUTO_COMPACT_WINDOW \u2014 auto-compact window (100000 to 1000000).
|
|
629
|
+
#
|
|
630
|
+
# ANTHROPIC_BASE_URL=https://gateway.example
|
|
631
|
+
# ANTHROPIC_AUTH_TOKEN=replace-with-your-gateway-token
|
|
632
|
+
# ANTHROPIC_DEFAULT_HAIKU_MODEL=replace-with-your-gateway-haiku-model
|
|
633
|
+
# ANTHROPIC_DEFAULT_SONNET_MODEL=replace-with-your-gateway-sonnet-model
|
|
634
|
+
# ANTHROPIC_DEFAULT_OPUS_MODEL=replace-with-your-gateway-opus-model
|
|
635
|
+
# API_TIMEOUT_MS=600000
|
|
636
|
+
# CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000
|
|
637
|
+
|
|
638
|
+
# --- Integrations ---
|
|
639
|
+
# CLICKUP_API_TOKEN \u2014 the token for the noir-clickup integration; set it only
|
|
640
|
+
# when that integration is enabled.
|
|
641
|
+
# CLICKUP_TEAM_ID is NOT an env var. Noir never reads it \u2014 workspace binding
|
|
642
|
+
# (team / list / space ids) belongs in .noir/config.yml under
|
|
643
|
+
# integrations.clickup.
|
|
644
|
+
# CLICKUP_API_TOKEN=replace-with-your-clickup-token
|
|
645
|
+
|
|
646
|
+
# --- Remote embedders (context.embedder.kind: remote) ---
|
|
647
|
+
# The default local embedder (kind: local) needs no key. The three API-key
|
|
648
|
+
# variables below apply only when config.yml selects a remote embedder.
|
|
649
|
+
# OPENAI_API_KEY=replace-with-your-openai-key
|
|
650
|
+
# VOYAGE_API_KEY=replace-with-your-voyage-key
|
|
651
|
+
# COHERE_API_KEY=replace-with-your-cohere-key
|
|
652
|
+
#
|
|
653
|
+
# Ollama (kind: ollama) reads its base URL from config.yml
|
|
654
|
+
# (context.embedder.baseURL); this variable is the fallback:
|
|
655
|
+
# OLLAMA_BASE_URL=http://localhost:11434
|
|
656
|
+
|
|
657
|
+
# --- Model provider key (read via apiKeyEnv in .noir/config.yml) ---
|
|
658
|
+
# There is no fixed "model key" variable, and setting a key is not enough on
|
|
659
|
+
# its own. \`apiKeyEnv\` is a NAME, not an interpolation: config.yml stores the
|
|
660
|
+
# variable NAME, and Noir reads process.env[<that name>] at call time. Write
|
|
661
|
+
# the bare name \u2014 never \`\${...}\` \u2014 in .noir/config.yml:
|
|
662
|
+
#
|
|
663
|
+
# model:
|
|
664
|
+
# providers:
|
|
665
|
+
# anthropic:
|
|
666
|
+
# apiKeyEnv: ANTHROPIC_API_KEY # -> reads $ANTHROPIC_API_KEY
|
|
667
|
+
#
|
|
668
|
+
# Only \`run.profiles.<name>.env\` interpolates \`\${VAR}\`. With no provider
|
|
669
|
+
# configured the model layer degrades to templates \u2014 Noir never makes a silent
|
|
670
|
+
# paid call. \`ANTHROPIC_API_KEY\` is the conventional example, not a variable
|
|
671
|
+
# Noir looks up on its own.
|
|
672
|
+
# ANTHROPIC_API_KEY=replace-with-your-anthropic-key
|
|
673
|
+
|
|
674
|
+
# --- Run profile selection (noir run) ---
|
|
675
|
+
# Selects a \`run.profiles.<name>\` entry for \`noir run\`.
|
|
676
|
+
# Precedence: --profile flag > NOIR_PROFILE > run.defaultProfile > host default.
|
|
677
|
+
# NOIR_PROFILE=work
|
|
678
|
+
|
|
679
|
+
# --- Update kill-switches ---
|
|
680
|
+
# NOIR_DISABLE_UPDATE_CHECK=1 # suppress the background startup check only
|
|
681
|
+
# NOIR_DISABLE_UPDATES=1 # make \`noir update\` refuse (exit 2)
|
|
682
|
+
|
|
683
|
+
# See docs/reference/environment.md for every variable Noir reads.
|
|
684
|
+
`;
|
|
685
|
+
var RULES_SEED_1_1_0 = `# Noir working rules
|
|
686
|
+
|
|
687
|
+
> Canonical AI working-contract for this project. The host context file (CLAUDE.md) @imports this.
|
|
688
|
+
> Keep LEAN: every line must be failure-backed, tool-enforceable, decision-encoding, or triggerable \u2014 else delete it.
|
|
689
|
+
> Edit freely \u2014 Noir re-emits only the @import pointer, never this body.
|
|
690
|
+
|
|
691
|
+
## Identity & scope
|
|
692
|
+
- This project uses **Noir** (discipline/context/memory layer) inside **Claude Code**.
|
|
693
|
+
- Noir is an orchestration layer \u2014 the host CLI is the execution engine; Noir is the spec/context/memory brain.
|
|
694
|
+
- Stay in scope: do only what the current Noir task (\`.noir/tasks/\`) requires. Surface scope creep BEFORE acting.
|
|
695
|
+
|
|
696
|
+
## Anti-assumption contract
|
|
697
|
+
- **Never fabricate** facts, APIs, file contents, or command output. Use only what you have read or verified.
|
|
698
|
+
- **Never assume** \u2014 if a path, signature, or convention is unclear, STOP and ask before acting.
|
|
699
|
+
- Cite the file/line or command you relied on for any non-obvious claim.
|
|
700
|
+
- Detect non-conventional setups; do not paper over them with assumed conventions.
|
|
701
|
+
|
|
702
|
+
## Spec-Driven Development workflow
|
|
703
|
+
- Follow the Noir SDD lifecycle: intake \u2192 clarify \u2192 spec \u2192 plan \u2192 execute \u2192 verify \u2192 document.
|
|
704
|
+
- Gates are observable: every gate decision is recorded (approved / forced / skipped). \`--force\` requires a reason.
|
|
705
|
+
- Do not edit/run/execute before facts are gathered AND the human confirms understanding.
|
|
706
|
+
|
|
707
|
+
## Verification (run before claiming done)
|
|
708
|
+
- \`pnpm build && pnpm lint && pnpm typecheck && pnpm test\` \u2014 all green, with real output. No hedging.
|
|
709
|
+
- Report failures truthfully with the actual output; never claim green without evidence.
|
|
710
|
+
|
|
711
|
+
## Coding standards & architecture
|
|
712
|
+
- Follow existing patterns; match surrounding code's style, naming, comment density.
|
|
713
|
+
- Architecture decisions live in \`.noir/decisions/\` (ADR-style) \u2014 read them before cross-cutting changes.
|
|
714
|
+
- See \`docs/roadmap.md\` for direction and \`docs/specs/\` for design.
|
|
715
|
+
|
|
716
|
+
## Conventions gotchas (project-specific \u2014 fill in)
|
|
717
|
+
- Commits stay local until explicitly pushed.
|
|
718
|
+
- The full test suite runs offline/free (no network in CI).
|
|
719
|
+
`;
|
|
720
|
+
var SEED_TEMPLATE_HISTORY = [
|
|
721
|
+
{
|
|
722
|
+
scaffoldVersion: "1.1.0",
|
|
723
|
+
envExample: ENV_EXAMPLE_1_1_0,
|
|
724
|
+
rulesSeed: RULES_SEED_1_1_0
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
scaffoldVersion: "1.2.0",
|
|
728
|
+
envExample: ENV_EXAMPLE_1_2_0,
|
|
729
|
+
rulesSeed: RULES_SEED_1_1_0
|
|
592
730
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
return
|
|
731
|
+
];
|
|
732
|
+
function isStaleSeed(seed, fileBytes, currentRender) {
|
|
733
|
+
if (fileBytes === currentRender) return false;
|
|
734
|
+
return SEED_TEMPLATE_HISTORY.some((entry) => entry[seed] === fileBytes);
|
|
597
735
|
}
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
736
|
+
|
|
737
|
+
// src/template-loader.ts
|
|
738
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
739
|
+
import { dirname as dirname2, join as join3, resolve } from "path";
|
|
740
|
+
import { fileURLToPath } from "url";
|
|
741
|
+
var DEFAULT_TEMPLATES_DIR = resolveTemplatesDir();
|
|
742
|
+
function resolveTemplatesDir() {
|
|
743
|
+
const override = process.env.NOIR_TEMPLATES_DIR;
|
|
744
|
+
if (override && override.length > 0) return resolve(override);
|
|
745
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
746
|
+
return join3(here, "..", "templates");
|
|
603
747
|
}
|
|
604
|
-
function
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
};
|
|
748
|
+
function loadTemplate(name) {
|
|
749
|
+
return readFileSync2(join3(DEFAULT_TEMPLATES_DIR, name), "utf8");
|
|
750
|
+
}
|
|
751
|
+
function templatesDir() {
|
|
752
|
+
return DEFAULT_TEMPLATES_DIR;
|
|
610
753
|
}
|
|
611
|
-
|
|
612
|
-
// src/scaffold.ts
|
|
613
|
-
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, renameSync as renameSync2, rmSync as rmSync2 } from "fs";
|
|
614
|
-
import { basename as basename2, dirname as dirname5, join as join8 } from "path";
|
|
615
|
-
import {
|
|
616
|
-
createProjectId,
|
|
617
|
-
paths as paths2,
|
|
618
|
-
readManagedBlock,
|
|
619
|
-
resolveNoirCommand,
|
|
620
|
-
sha256Hex12
|
|
621
|
-
} from "@noir-ai/core";
|
|
622
|
-
|
|
623
|
-
// src/scaffold-version.ts
|
|
624
|
-
import { mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "fs";
|
|
625
|
-
import { dirname as dirname3, join as join5 } from "path";
|
|
626
|
-
import { NOIR_DIR as NOIR_DIR2 } from "@noir-ai/core";
|
|
627
754
|
|
|
628
755
|
// src/writers.ts
|
|
629
756
|
import {
|
|
630
757
|
closeSync,
|
|
631
|
-
existsSync as
|
|
758
|
+
existsSync as existsSync3,
|
|
632
759
|
openSync,
|
|
633
760
|
readFileSync as readFileSync3,
|
|
634
761
|
renameSync,
|
|
635
762
|
rmSync,
|
|
636
|
-
writeFileSync as
|
|
763
|
+
writeFileSync as writeFileSync2,
|
|
637
764
|
writeSync
|
|
638
765
|
} from "fs";
|
|
639
|
-
import { basename, dirname as
|
|
766
|
+
import { basename, dirname as dirname3, join as join4 } from "path";
|
|
640
767
|
import { atomicWriteFile, stripManagedBlock, writeManagedRegion } from "@noir-ai/core";
|
|
641
768
|
function regenerate(absPath, content) {
|
|
642
|
-
const dir =
|
|
769
|
+
const dir = dirname3(absPath);
|
|
643
770
|
const tmp = join4(
|
|
644
771
|
dir,
|
|
645
772
|
`.${basename(absPath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
|
|
@@ -691,7 +818,7 @@ function managedBlocks(absPath, regions) {
|
|
|
691
818
|
const next = stripped.trim().length > 0 ? `${stripped.trimEnd()}
|
|
692
819
|
|
|
693
820
|
${regionsJoined}` : regionsJoined;
|
|
694
|
-
|
|
821
|
+
writeFileSync2(absPath, next, "utf8");
|
|
695
822
|
return { path: absPath, mode: "managedBlock", written: true };
|
|
696
823
|
}
|
|
697
824
|
function buildRegion(block, body) {
|
|
@@ -725,15 +852,19 @@ function predictManagedBlocks(currentContent, regions) {
|
|
|
725
852
|
${regionsJoined}` : regionsJoined;
|
|
726
853
|
}
|
|
727
854
|
function skipIfExists(absPath, content, fileMode) {
|
|
728
|
-
if (
|
|
855
|
+
if (existsSync3(absPath)) {
|
|
729
856
|
return { path: absPath, mode: "skipIfExists", written: false };
|
|
730
857
|
}
|
|
731
858
|
atomicWriteFile(absPath, content, fileMode !== void 0 ? { mode: fileMode } : {});
|
|
732
859
|
return { path: absPath, mode: "skipIfExists", written: true };
|
|
733
860
|
}
|
|
861
|
+
function refreshSeed(absPath, content) {
|
|
862
|
+
atomicWriteFile(absPath, content);
|
|
863
|
+
return { path: absPath, mode: "skipIfExists", written: true };
|
|
864
|
+
}
|
|
734
865
|
function mergeJson(absPath, patch, dedupSubstring) {
|
|
735
866
|
let existing = {};
|
|
736
|
-
if (
|
|
867
|
+
if (existsSync3(absPath)) {
|
|
737
868
|
try {
|
|
738
869
|
existing = JSON.parse(readFileSync3(absPath, "utf8"));
|
|
739
870
|
} catch {
|
|
@@ -743,7 +874,7 @@ function mergeJson(absPath, patch, dedupSubstring) {
|
|
|
743
874
|
const merged = deepMergePreservingHooks(existing, patch, dedupSubstring);
|
|
744
875
|
const content = `${JSON.stringify(merged, null, 2)}
|
|
745
876
|
`;
|
|
746
|
-
if (
|
|
877
|
+
if (existsSync3(absPath)) {
|
|
747
878
|
try {
|
|
748
879
|
if (readFileSync3(absPath, "utf8") === content) {
|
|
749
880
|
return { path: absPath, mode: "mergeJson", written: false };
|
|
@@ -777,16 +908,214 @@ function deepMergePreservingHooks(existing, patch, dedupSubstring) {
|
|
|
777
908
|
return out;
|
|
778
909
|
}
|
|
779
910
|
|
|
911
|
+
// src/migrations/runner.ts
|
|
912
|
+
function runMigrations(root, from, to, opts = {}) {
|
|
913
|
+
const window = pickWindow(from ?? "0.0.0", to, MIGRATIONS);
|
|
914
|
+
const ctx = { root, dryRun: opts.dryRun === true };
|
|
915
|
+
const aggregate = { changed: [], conflicts: [], notes: [] };
|
|
916
|
+
const ran = [];
|
|
917
|
+
for (const script of window) {
|
|
918
|
+
ran.push(`${script.from}\u2192${script.to}`);
|
|
919
|
+
let res;
|
|
920
|
+
try {
|
|
921
|
+
res = script.run(ctx);
|
|
922
|
+
} catch (err) {
|
|
923
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
924
|
+
aggregate.conflicts.push(`<runner>:${script.from}\u2192${script.to} threw: ${msg}`);
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
aggregate.changed.push(...res.changed);
|
|
928
|
+
aggregate.conflicts.push(...res.conflicts);
|
|
929
|
+
aggregate.notes.push(...res.notes);
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
...aggregate,
|
|
933
|
+
from,
|
|
934
|
+
to,
|
|
935
|
+
ran
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
function pickWindow(from, to, scripts) {
|
|
939
|
+
return scripts.filter((s) => compareVer(s.from) >= compareVer(from) && compareVer(s.to) <= compareVer(to)).sort((a, b) => compareVer(a.to) - compareVer(b.to));
|
|
940
|
+
}
|
|
941
|
+
function compareVer(v) {
|
|
942
|
+
const parts = v.split("-")[0]?.split(".") ?? [];
|
|
943
|
+
let n = 0;
|
|
944
|
+
for (let i = 0; i < 3; i++) {
|
|
945
|
+
const p = Number(parts[i] ?? "0");
|
|
946
|
+
n = n * 1e3 + (Number.isFinite(p) ? p : 0);
|
|
947
|
+
}
|
|
948
|
+
return n;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// src/migrations/index.ts
|
|
952
|
+
var synthetic = {
|
|
953
|
+
from: "1.0.0",
|
|
954
|
+
to: "1.0.0",
|
|
955
|
+
description: "no-op synthetic migration (runner smoke test)",
|
|
956
|
+
run: (ctx) => {
|
|
957
|
+
const result = { changed: [], conflicts: [], notes: [] };
|
|
958
|
+
if (process.env.NOIR_TEST_FORCE_CONFLICT === "1" && !ctx.dryRun) {
|
|
959
|
+
const file = join5(ctx.root, ".noir", "scaffold-version");
|
|
960
|
+
if (existsSync4(file)) {
|
|
961
|
+
const prev = readFileSync4(file, "utf8");
|
|
962
|
+
const merged = applyInlineConflict(prev, "noir-scaffold=1.0.0\n", "ours", "theirs");
|
|
963
|
+
writeFileSync3(file, merged, "utf8");
|
|
964
|
+
result.conflicts.push(".noir/scaffold-version");
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
result.notes.push("synthetic 1.0.0\u21921.0.0 migration ran");
|
|
968
|
+
return result;
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
var ENV_POINTER_MARKER = "# noir:env-pointer";
|
|
972
|
+
var ENV_POINTER_BLOCK = [
|
|
973
|
+
ENV_POINTER_MARKER,
|
|
974
|
+
"# This file is safe to commit: it holds no secrets.",
|
|
975
|
+
"# Project-scoped values and secrets belong in .noir/.env (gitignored, mode",
|
|
976
|
+
"# 0600). .noir/.env.example documents the full variable set; `noir env`",
|
|
977
|
+
"# reports which source wins for each key.",
|
|
978
|
+
""
|
|
979
|
+
].join("\n");
|
|
980
|
+
function appendSeparated(prev, block) {
|
|
981
|
+
if (prev.length === 0) return block;
|
|
982
|
+
if (prev.endsWith("\n\n")) return `${prev}${block}`;
|
|
983
|
+
return prev.endsWith("\n") ? `${prev}
|
|
984
|
+
${block}` : `${prev}
|
|
985
|
+
|
|
986
|
+
${block}`;
|
|
987
|
+
}
|
|
988
|
+
var envPointer = {
|
|
989
|
+
from: "1.0.0",
|
|
990
|
+
to: "1.1.0",
|
|
991
|
+
description: "point .noir/config.yml at .noir/.env (the secrets home)",
|
|
992
|
+
run: (ctx) => {
|
|
993
|
+
const result = { changed: [], conflicts: [], notes: [] };
|
|
994
|
+
const rel = ".noir/config.yml";
|
|
995
|
+
const abs = join5(ctx.root, ".noir", "config.yml");
|
|
996
|
+
if (!existsSync4(abs)) {
|
|
997
|
+
result.notes.push(`${rel}: absent \u2014 the template seed carries the pointer`);
|
|
998
|
+
return result;
|
|
999
|
+
}
|
|
1000
|
+
let prev;
|
|
1001
|
+
try {
|
|
1002
|
+
prev = readFileSync4(abs, "utf8");
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
result.conflicts.push(rel);
|
|
1005
|
+
result.notes.push(`${rel}: unreadable (${errorMessage(err)}) \u2014 left untouched`);
|
|
1006
|
+
return result;
|
|
1007
|
+
}
|
|
1008
|
+
if (prev.includes(ENV_POINTER_MARKER)) {
|
|
1009
|
+
result.notes.push(`${rel}: already carries ${ENV_POINTER_MARKER}`);
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
if (ctx.dryRun) {
|
|
1013
|
+
result.changed.push(rel);
|
|
1014
|
+
result.notes.push(`${rel}: would append the .noir/.env pointer`);
|
|
1015
|
+
return result;
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
writeFileSync3(abs, appendSeparated(prev, ENV_POINTER_BLOCK), "utf8");
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
result.conflicts.push(rel);
|
|
1021
|
+
result.notes.push(`${rel}: write failed (${errorMessage(err)}) \u2014 left untouched`);
|
|
1022
|
+
return result;
|
|
1023
|
+
}
|
|
1024
|
+
result.changed.push(rel);
|
|
1025
|
+
result.notes.push(`${rel}: appended the .noir/.env pointer`);
|
|
1026
|
+
return result;
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
var DOC_SEEDS = [
|
|
1030
|
+
{ rel: ".noir/.env.example", kind: "envExample", template: "env.example.tmpl" },
|
|
1031
|
+
{ rel: ".noir/rules/RULES.md", kind: "rulesSeed", template: "rules-seed.md.tmpl" }
|
|
1032
|
+
];
|
|
1033
|
+
var envTemplates = {
|
|
1034
|
+
from: "1.1.0",
|
|
1035
|
+
to: "1.2.0",
|
|
1036
|
+
description: "refresh the .env.example doc seed and keep RULES.md in step",
|
|
1037
|
+
run: (ctx) => {
|
|
1038
|
+
const result = { changed: [], conflicts: [], notes: [] };
|
|
1039
|
+
for (const seed of DOC_SEEDS) {
|
|
1040
|
+
const abs = join5(ctx.root, seed.rel);
|
|
1041
|
+
if (!existsSync4(abs)) {
|
|
1042
|
+
result.notes.push(`${seed.rel}: absent \u2014 nothing to refresh`);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
let prev;
|
|
1046
|
+
try {
|
|
1047
|
+
prev = readFileSync4(abs, "utf8");
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
result.conflicts.push(seed.rel);
|
|
1050
|
+
result.notes.push(`${seed.rel}: unreadable (${errorMessage(err)}) \u2014 left untouched`);
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
const current = render(loadTemplate(seed.template), {});
|
|
1054
|
+
if (!isStaleSeed(seed.kind, prev, current)) {
|
|
1055
|
+
result.notes.push(`${seed.rel}: user-owned or already current \u2014 left untouched`);
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
if (ctx.dryRun) {
|
|
1059
|
+
result.changed.push(seed.rel);
|
|
1060
|
+
result.notes.push(`${seed.rel}: would refresh to the current seed`);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
try {
|
|
1064
|
+
refreshSeed(abs, current);
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
result.conflicts.push(seed.rel);
|
|
1067
|
+
result.notes.push(`${seed.rel}: refresh failed (${errorMessage(err)}) \u2014 left untouched`);
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
result.changed.push(seed.rel);
|
|
1071
|
+
result.notes.push(`${seed.rel}: refreshed to the current seed`);
|
|
1072
|
+
}
|
|
1073
|
+
return result;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
var MIGRATIONS = [synthetic, envPointer, envTemplates];
|
|
1077
|
+
function errorMessage(err) {
|
|
1078
|
+
return err instanceof Error ? err.message : String(err);
|
|
1079
|
+
}
|
|
1080
|
+
function applyInlineConflict(ours, theirs, oursLabel = "ours", theirsLabel = "theirs") {
|
|
1081
|
+
return `<<<<<<< ${oursLabel}
|
|
1082
|
+
${ours}=======
|
|
1083
|
+
${theirs}>>>>>>> ${theirsLabel}
|
|
1084
|
+
`;
|
|
1085
|
+
}
|
|
1086
|
+
function applyWithConflict(ours, theirs, path) {
|
|
1087
|
+
if (ours === theirs) return { text: ours, conflicted: false };
|
|
1088
|
+
return {
|
|
1089
|
+
text: applyInlineConflict(ours, theirs, path, path),
|
|
1090
|
+
conflicted: true
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/scaffold.ts
|
|
1095
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, renameSync as renameSync2, rmSync as rmSync2 } from "fs";
|
|
1096
|
+
import { basename as basename2, dirname as dirname5, join as join8 } from "path";
|
|
1097
|
+
import {
|
|
1098
|
+
createProjectId,
|
|
1099
|
+
loadProjectInfo,
|
|
1100
|
+
paths as paths2,
|
|
1101
|
+
readManagedBlock,
|
|
1102
|
+
resolveNoirCommand,
|
|
1103
|
+
sha256Hex12
|
|
1104
|
+
} from "@noir-ai/core";
|
|
1105
|
+
|
|
780
1106
|
// src/scaffold-version.ts
|
|
781
|
-
|
|
1107
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync5 } from "fs";
|
|
1108
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
1109
|
+
import { NOIR_DIR as NOIR_DIR2 } from "@noir-ai/core";
|
|
1110
|
+
var CURRENT_SCAFFOLD_VERSION = "1.2.0";
|
|
782
1111
|
var PREFIX = "noir-scaffold=";
|
|
783
1112
|
function scaffoldVersionPath(root) {
|
|
784
|
-
return
|
|
1113
|
+
return join6(root, NOIR_DIR2, "scaffold-version");
|
|
785
1114
|
}
|
|
786
1115
|
function readScaffoldVersion(root) {
|
|
787
1116
|
let raw;
|
|
788
1117
|
try {
|
|
789
|
-
raw =
|
|
1118
|
+
raw = readFileSync5(scaffoldVersionPath(root), "utf8");
|
|
790
1119
|
} catch {
|
|
791
1120
|
return null;
|
|
792
1121
|
}
|
|
@@ -801,14 +1130,14 @@ function readScaffoldVersion(root) {
|
|
|
801
1130
|
}
|
|
802
1131
|
function writeScaffoldVersion(root, version) {
|
|
803
1132
|
const file = scaffoldVersionPath(root);
|
|
804
|
-
mkdirSync2(
|
|
1133
|
+
mkdirSync2(dirname4(file), { recursive: true });
|
|
805
1134
|
regenerate(file, `${PREFIX}${version}
|
|
806
1135
|
`);
|
|
807
1136
|
}
|
|
808
1137
|
|
|
809
1138
|
// src/stack-detect.ts
|
|
810
|
-
import { existsSync as existsSync5, readdirSync, readFileSync as
|
|
811
|
-
import { join as
|
|
1139
|
+
import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync6 } from "fs";
|
|
1140
|
+
import { join as join7 } from "path";
|
|
812
1141
|
var NODE_FRAMEWORKS = [
|
|
813
1142
|
["next", "next"],
|
|
814
1143
|
["vite", "vite"],
|
|
@@ -835,7 +1164,7 @@ var PYTHON_FRAMEWORKS = [
|
|
|
835
1164
|
];
|
|
836
1165
|
function readJson(file) {
|
|
837
1166
|
try {
|
|
838
|
-
return JSON.parse(
|
|
1167
|
+
return JSON.parse(readFileSync6(file, "utf8"));
|
|
839
1168
|
} catch {
|
|
840
1169
|
return void 0;
|
|
841
1170
|
}
|
|
@@ -845,11 +1174,11 @@ function detectStack(root) {
|
|
|
845
1174
|
const frameworks = /* @__PURE__ */ new Set();
|
|
846
1175
|
let monorepo = false;
|
|
847
1176
|
let packageManager = null;
|
|
848
|
-
const pjPath =
|
|
1177
|
+
const pjPath = join7(root, "package.json");
|
|
849
1178
|
if (existsSync5(pjPath)) {
|
|
850
1179
|
const pj = readJson(pjPath);
|
|
851
1180
|
if (pj) {
|
|
852
|
-
const hasTs = Boolean(pj.devDependencies?.typescript) || existsSync5(
|
|
1181
|
+
const hasTs = Boolean(pj.devDependencies?.typescript) || existsSync5(join7(root, "tsconfig.json"));
|
|
853
1182
|
languages.add(hasTs ? "typescript" : "javascript");
|
|
854
1183
|
const deps = /* @__PURE__ */ new Set([
|
|
855
1184
|
...Object.keys(pj.dependencies ?? {}),
|
|
@@ -868,22 +1197,22 @@ function detectStack(root) {
|
|
|
868
1197
|
}
|
|
869
1198
|
}
|
|
870
1199
|
}
|
|
871
|
-
if (existsSync5(
|
|
1200
|
+
if (existsSync5(join7(root, "pnpm-workspace.yaml"))) {
|
|
872
1201
|
monorepo = true;
|
|
873
1202
|
packageManager = packageManager ?? "pnpm";
|
|
874
1203
|
}
|
|
875
|
-
if (existsSync5(
|
|
1204
|
+
if (existsSync5(join7(root, "turbo.json")) || existsSync5(join7(root, "nx.json"))) {
|
|
876
1205
|
monorepo = true;
|
|
877
1206
|
}
|
|
878
1207
|
if (!packageManager) {
|
|
879
|
-
if (existsSync5(
|
|
880
|
-
else if (existsSync5(
|
|
881
|
-
else if (existsSync5(
|
|
1208
|
+
if (existsSync5(join7(root, "pnpm-lock.yaml"))) packageManager = "pnpm";
|
|
1209
|
+
else if (existsSync5(join7(root, "yarn.lock"))) packageManager = "yarn";
|
|
1210
|
+
else if (existsSync5(join7(root, "package-lock.json"))) packageManager = "npm";
|
|
882
1211
|
}
|
|
883
|
-
if (existsSync5(
|
|
1212
|
+
if (existsSync5(join7(root, "pyproject.toml")) || existsSync5(join7(root, "requirements.txt")) || existsSync5(join7(root, "Pipfile")) || existsSync5(join7(root, "setup.py"))) {
|
|
884
1213
|
languages.add("python");
|
|
885
|
-
if (existsSync5(
|
|
886
|
-
const raw = safeRead(
|
|
1214
|
+
if (existsSync5(join7(root, "pyproject.toml"))) {
|
|
1215
|
+
const raw = safeRead(join7(root, "pyproject.toml"));
|
|
887
1216
|
if (raw) {
|
|
888
1217
|
for (const [dep, id] of PYTHON_FRAMEWORKS) {
|
|
889
1218
|
if (pyprojectHasDep(raw, dep)) frameworks.add(id);
|
|
@@ -891,13 +1220,13 @@ function detectStack(root) {
|
|
|
891
1220
|
}
|
|
892
1221
|
}
|
|
893
1222
|
}
|
|
894
|
-
if (existsSync5(
|
|
1223
|
+
if (existsSync5(join7(root, "go.mod"))) {
|
|
895
1224
|
languages.add("go");
|
|
896
1225
|
packageManager = packageManager ?? "go-modules";
|
|
897
1226
|
}
|
|
898
|
-
if (existsSync5(
|
|
1227
|
+
if (existsSync5(join7(root, "Cargo.toml"))) {
|
|
899
1228
|
languages.add("rust");
|
|
900
|
-
const raw = safeRead(
|
|
1229
|
+
const raw = safeRead(join7(root, "Cargo.toml"));
|
|
901
1230
|
if (raw) {
|
|
902
1231
|
if (/^\s*actix-web\b/m.test(raw)) frameworks.add("actix-web");
|
|
903
1232
|
if (/^\s*actix\s*=/m.test(raw)) frameworks.add("actix");
|
|
@@ -938,10 +1267,10 @@ function detectStack(root) {
|
|
|
938
1267
|
}
|
|
939
1268
|
}
|
|
940
1269
|
let ci = null;
|
|
941
|
-
if (existsSync5(
|
|
942
|
-
else if (existsSync5(
|
|
943
|
-
else if (existsSync5(
|
|
944
|
-
else if (existsSync5(
|
|
1270
|
+
if (existsSync5(join7(root, ".github", "workflows"))) ci = "github";
|
|
1271
|
+
else if (existsSync5(join7(root, ".gitlab-ci.yml"))) ci = "gitlab";
|
|
1272
|
+
else if (existsSync5(join7(root, ".circleci", "config.yml"))) ci = "circleci";
|
|
1273
|
+
else if (existsSync5(join7(root, "Jenkinsfile"))) ci = "jenkins";
|
|
945
1274
|
const aiFiles = [
|
|
946
1275
|
["AGENTS.md", "agents-md"],
|
|
947
1276
|
["CLAUDE.md", "claude"],
|
|
@@ -951,10 +1280,10 @@ function detectStack(root) {
|
|
|
951
1280
|
];
|
|
952
1281
|
const existingAiFiles = [];
|
|
953
1282
|
for (const [p, kind] of aiFiles) {
|
|
954
|
-
const full =
|
|
1283
|
+
const full = join7(root, p);
|
|
955
1284
|
if (existsSync5(full)) existingAiFiles.push({ path: p, kind });
|
|
956
1285
|
}
|
|
957
|
-
const cursorRulesDir =
|
|
1286
|
+
const cursorRulesDir = join7(root, ".cursor", "rules");
|
|
958
1287
|
try {
|
|
959
1288
|
if (existsSync5(cursorRulesDir)) {
|
|
960
1289
|
const entries = readdirSync(cursorRulesDir);
|
|
@@ -978,7 +1307,7 @@ function detectStack(root) {
|
|
|
978
1307
|
}
|
|
979
1308
|
function safeRead(file) {
|
|
980
1309
|
try {
|
|
981
|
-
return
|
|
1310
|
+
return readFileSync6(file, "utf8");
|
|
982
1311
|
} catch {
|
|
983
1312
|
return void 0;
|
|
984
1313
|
}
|
|
@@ -990,35 +1319,6 @@ function pyprojectHasDep(raw, dep) {
|
|
|
990
1319
|
return new RegExp(`${before}${esc}${after}`, "m").test(raw);
|
|
991
1320
|
}
|
|
992
1321
|
|
|
993
|
-
// src/template.ts
|
|
994
|
-
var TOKEN = /\{\{\s*([^}{}]+?)\s*\}\}/g;
|
|
995
|
-
function render(template, vars) {
|
|
996
|
-
return template.replace(TOKEN, (whole, name) => {
|
|
997
|
-
if (!Object.hasOwn(vars, name)) return whole;
|
|
998
|
-
const v = vars[name];
|
|
999
|
-
if (v === void 0 || v === null) return whole;
|
|
1000
|
-
return String(v);
|
|
1001
|
-
});
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
// src/template-loader.ts
|
|
1005
|
-
import { readFileSync as readFileSync6 } from "fs";
|
|
1006
|
-
import { dirname as dirname4, join as join7, resolve } from "path";
|
|
1007
|
-
import { fileURLToPath } from "url";
|
|
1008
|
-
var DEFAULT_TEMPLATES_DIR = resolveTemplatesDir();
|
|
1009
|
-
function resolveTemplatesDir() {
|
|
1010
|
-
const override = process.env.NOIR_TEMPLATES_DIR;
|
|
1011
|
-
if (override && override.length > 0) return resolve(override);
|
|
1012
|
-
const here = dirname4(fileURLToPath(import.meta.url));
|
|
1013
|
-
return join7(here, "..", "templates");
|
|
1014
|
-
}
|
|
1015
|
-
function loadTemplate(name) {
|
|
1016
|
-
return readFileSync6(join7(DEFAULT_TEMPLATES_DIR, name), "utf8");
|
|
1017
|
-
}
|
|
1018
|
-
function templatesDir() {
|
|
1019
|
-
return DEFAULT_TEMPLATES_DIR;
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
1322
|
// src/scaffold.ts
|
|
1023
1323
|
var EMITTED_IN = {
|
|
1024
1324
|
regenerate: { sync: true, upgrade: true },
|
|
@@ -1039,9 +1339,17 @@ function assertSafeRoot(root) {
|
|
|
1039
1339
|
cur = parent;
|
|
1040
1340
|
}
|
|
1041
1341
|
}
|
|
1342
|
+
function readConfiguredHost(root) {
|
|
1343
|
+
if (!existsSync6(paths2.config(root))) return "claude";
|
|
1344
|
+
try {
|
|
1345
|
+
return loadProjectInfo(root).config.host;
|
|
1346
|
+
} catch {
|
|
1347
|
+
return "claude";
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1042
1350
|
async function scaffold(opts) {
|
|
1043
1351
|
assertSafeRoot(opts.root);
|
|
1044
|
-
const host = opts.host ??
|
|
1352
|
+
const host = opts.host ?? readConfiguredHost(opts.root);
|
|
1045
1353
|
const transport = opts.transport ?? "stdio";
|
|
1046
1354
|
if (transport === "streamable-http" && !opts.url) {
|
|
1047
1355
|
throw new Error("transport 'streamable-http' requires opts.url");
|
|
@@ -1071,11 +1379,13 @@ async function scaffold(opts) {
|
|
|
1071
1379
|
written: [],
|
|
1072
1380
|
skipped: [],
|
|
1073
1381
|
identical: [],
|
|
1382
|
+
refreshed: [],
|
|
1074
1383
|
conflicts: [],
|
|
1075
1384
|
fileModes: {},
|
|
1076
1385
|
noop: true,
|
|
1077
1386
|
migrationsRan: [],
|
|
1078
1387
|
migrationConflicts: [],
|
|
1388
|
+
migrationChanged: [],
|
|
1079
1389
|
stack,
|
|
1080
1390
|
projectId,
|
|
1081
1391
|
fromVersion,
|
|
@@ -1085,12 +1395,14 @@ async function scaffold(opts) {
|
|
|
1085
1395
|
}
|
|
1086
1396
|
const migrationsRan = [];
|
|
1087
1397
|
const migrationConflicts = [];
|
|
1088
|
-
|
|
1398
|
+
const migrationChanged = [];
|
|
1399
|
+
if (opts.mode === "init" && opts.upgrade === true && (fromVersion !== null || idFile.state === "valid")) {
|
|
1089
1400
|
const m = runMigrations(opts.root, fromVersion, CURRENT_SCAFFOLD_VERSION, {
|
|
1090
1401
|
dryRun: opts.dryRun === true
|
|
1091
1402
|
});
|
|
1092
1403
|
migrationsRan.push(...m.ran);
|
|
1093
1404
|
migrationConflicts.push(...m.conflicts);
|
|
1405
|
+
migrationChanged.push(...m.changed);
|
|
1094
1406
|
}
|
|
1095
1407
|
const command = resolveNoirCommand();
|
|
1096
1408
|
const manifest = buildManifest({
|
|
@@ -1114,6 +1426,7 @@ async function scaffold(opts) {
|
|
|
1114
1426
|
const written = [];
|
|
1115
1427
|
const skipped = [];
|
|
1116
1428
|
const identical = [];
|
|
1429
|
+
const refreshed = [];
|
|
1117
1430
|
const fileModes = {};
|
|
1118
1431
|
const conflictMemory = /* @__PURE__ */ new Map();
|
|
1119
1432
|
const conflictRecords = [];
|
|
@@ -1180,21 +1493,34 @@ async function scaffold(opts) {
|
|
|
1180
1493
|
if (!opts.dryRun) mkdirSync3(dirname5(abs), { recursive: true });
|
|
1181
1494
|
for (const entry of entries) {
|
|
1182
1495
|
if (opts.dryRun) {
|
|
1183
|
-
if (entry.mode
|
|
1496
|
+
if (entry.mode !== "skipIfExists") {
|
|
1497
|
+
written.push(entry.path);
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
const seedKind = refreshableSeedKind(entry, emitContext);
|
|
1501
|
+
if (seedKind === void 0) {
|
|
1184
1502
|
if (existsSync6(abs)) {
|
|
1185
1503
|
skipped.push(entry.path);
|
|
1186
1504
|
} else {
|
|
1187
1505
|
written.push(entry.path);
|
|
1188
1506
|
if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
|
|
1189
1507
|
}
|
|
1190
|
-
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
const plan = planSeedWrite(abs, seedKind, renderEntry(entry, vars));
|
|
1511
|
+
if (plan === "create") {
|
|
1191
1512
|
written.push(entry.path);
|
|
1513
|
+
if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
|
|
1514
|
+
} else if (plan === "refresh") {
|
|
1515
|
+
refreshed.push(entry.path);
|
|
1516
|
+
} else {
|
|
1517
|
+
skipped.push(entry.path);
|
|
1192
1518
|
}
|
|
1193
1519
|
continue;
|
|
1194
1520
|
}
|
|
1195
1521
|
const body = renderEntry(entry, vars);
|
|
1196
1522
|
if (entry.mode === "regenerate") {
|
|
1197
|
-
const out = await
|
|
1523
|
+
const out = await writeWithConflict(abs, entry.path, body, opts, {
|
|
1198
1524
|
memory: conflictMemory,
|
|
1199
1525
|
record: recordConflict
|
|
1200
1526
|
});
|
|
@@ -1249,12 +1575,40 @@ async function scaffold(opts) {
|
|
|
1249
1575
|
if (out.written) written.push(entry.path);
|
|
1250
1576
|
else identical.push(entry.path);
|
|
1251
1577
|
} else {
|
|
1252
|
-
const
|
|
1253
|
-
if (
|
|
1578
|
+
const seedKind = refreshableSeedKind(entry, emitContext);
|
|
1579
|
+
if (seedKind === void 0) {
|
|
1580
|
+
const out = skipIfExists(abs, body, entry.fileMode);
|
|
1581
|
+
if (out.written) {
|
|
1582
|
+
written.push(entry.path);
|
|
1583
|
+
if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
|
|
1584
|
+
} else {
|
|
1585
|
+
skipped.push(entry.path);
|
|
1586
|
+
}
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
const plan = planSeedWrite(abs, seedKind, body);
|
|
1590
|
+
if (plan === "create") {
|
|
1591
|
+
skipIfExists(abs, body, entry.fileMode);
|
|
1254
1592
|
written.push(entry.path);
|
|
1255
1593
|
if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
|
|
1256
|
-
} else {
|
|
1594
|
+
} else if (plan === "refresh") {
|
|
1595
|
+
refreshSeed(abs, body);
|
|
1596
|
+
refreshed.push(entry.path);
|
|
1597
|
+
} else if (plan === "keep") {
|
|
1257
1598
|
skipped.push(entry.path);
|
|
1599
|
+
} else {
|
|
1600
|
+
const out = await writeWithConflict(abs, entry.path, body, opts, {
|
|
1601
|
+
memory: conflictMemory,
|
|
1602
|
+
record: recordConflict,
|
|
1603
|
+
artifactClass: "skipIfExists",
|
|
1604
|
+
// No resolver answered (no TTY, `--no-input`, CI): keep the user's
|
|
1605
|
+
// file. An explicit overwrite policy is an instruction rather than a
|
|
1606
|
+
// missing answer, so `--force` still replaces.
|
|
1607
|
+
defaultResolution: opts.conflictPolicy === "overwrite" ? "replace" : "preserve"
|
|
1608
|
+
});
|
|
1609
|
+
written.push(...out.written);
|
|
1610
|
+
skipped.push(...out.skipped);
|
|
1611
|
+
identical.push(...out.identical);
|
|
1258
1612
|
}
|
|
1259
1613
|
}
|
|
1260
1614
|
}
|
|
@@ -1269,11 +1623,13 @@ async function scaffold(opts) {
|
|
|
1269
1623
|
written,
|
|
1270
1624
|
skipped,
|
|
1271
1625
|
identical,
|
|
1626
|
+
refreshed,
|
|
1272
1627
|
conflicts: conflictRecords,
|
|
1273
1628
|
fileModes,
|
|
1274
1629
|
noop: false,
|
|
1275
1630
|
migrationsRan,
|
|
1276
1631
|
migrationConflicts,
|
|
1632
|
+
migrationChanged,
|
|
1277
1633
|
stack,
|
|
1278
1634
|
projectId,
|
|
1279
1635
|
fromVersion,
|
|
@@ -1347,7 +1703,29 @@ function resolveProjectId(opts, idFile) {
|
|
|
1347
1703
|
}
|
|
1348
1704
|
return createProjectId();
|
|
1349
1705
|
}
|
|
1350
|
-
|
|
1706
|
+
function planSeedWrite(abs, seed, currentRender) {
|
|
1707
|
+
let existing;
|
|
1708
|
+
try {
|
|
1709
|
+
existing = readOptional(abs);
|
|
1710
|
+
} catch {
|
|
1711
|
+
return "keep";
|
|
1712
|
+
}
|
|
1713
|
+
if (existing === void 0) return "create";
|
|
1714
|
+
if (isStaleSeed(seed, existing, currentRender)) return "refresh";
|
|
1715
|
+
if (existing === currentRender) return "keep";
|
|
1716
|
+
return "conflict";
|
|
1717
|
+
}
|
|
1718
|
+
function refreshableSeedKind(entry, emitContext) {
|
|
1719
|
+
if (emitContext !== "upgrade" || entry.refreshIfStale !== true) return void 0;
|
|
1720
|
+
const kind = REFRESHABLE_SEED_KIND[entry.path];
|
|
1721
|
+
if (kind === void 0) {
|
|
1722
|
+
throw new Error(
|
|
1723
|
+
`manifest entry ${entry.path}: 'refreshIfStale' is set but no recorded seed is declared for that path`
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
return kind;
|
|
1727
|
+
}
|
|
1728
|
+
async function writeWithConflict(abs, relPath, proposed, opts, internals) {
|
|
1351
1729
|
let existing;
|
|
1352
1730
|
try {
|
|
1353
1731
|
existing = readFileSync7(abs, "utf8");
|
|
@@ -1361,7 +1739,7 @@ async function writeRegenerateWithConflict(abs, relPath, proposed, opts, interna
|
|
|
1361
1739
|
if (existing === proposed) {
|
|
1362
1740
|
return { written: [], skipped: [], identical: [relPath] };
|
|
1363
1741
|
}
|
|
1364
|
-
const MODE = "regenerate";
|
|
1742
|
+
const MODE = internals.artifactClass ?? "regenerate";
|
|
1365
1743
|
const sim = similarity(existing, proposed);
|
|
1366
1744
|
let resolution;
|
|
1367
1745
|
const remembered = internals.memory.get(MODE);
|
|
@@ -1375,7 +1753,7 @@ async function writeRegenerateWithConflict(abs, relPath, proposed, opts, interna
|
|
|
1375
1753
|
}
|
|
1376
1754
|
resolution = unwrapped;
|
|
1377
1755
|
} else {
|
|
1378
|
-
resolution = opts.conflictPolicy === "preserve" ? "preserve" : "replace";
|
|
1756
|
+
resolution = opts.conflictPolicy === "preserve" ? "preserve" : internals.defaultResolution ?? "replace";
|
|
1379
1757
|
}
|
|
1380
1758
|
internals.record(relPath, MODE, existing, proposed, resolution, sim);
|
|
1381
1759
|
switch (resolution) {
|
|
@@ -1460,6 +1838,7 @@ export {
|
|
|
1460
1838
|
CURRENT_SCAFFOLD_VERSION,
|
|
1461
1839
|
MANIFEST_PATH_PARITY,
|
|
1462
1840
|
MIGRATIONS,
|
|
1841
|
+
SEED_TEMPLATE_HISTORY,
|
|
1463
1842
|
ancestorsPath,
|
|
1464
1843
|
applyInlineConflict,
|
|
1465
1844
|
applyWithConflict,
|
|
@@ -1468,6 +1847,7 @@ export {
|
|
|
1468
1847
|
buildManifest,
|
|
1469
1848
|
buildRegion,
|
|
1470
1849
|
detectStack,
|
|
1850
|
+
isStaleSeed,
|
|
1471
1851
|
lineDiff,
|
|
1472
1852
|
loadTemplate,
|
|
1473
1853
|
managedBlock2 as managedBlock,
|