@noir-ai/create 1.13.0-beta.1 → 1.14.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 CHANGED
@@ -123,8 +123,22 @@ declare function managedBlock(absPath: string, block: ManagedBlock, regionText:
123
123
  * S-T2 relies on for a diff-free refactor. */
124
124
  declare function buildRegion(block: ManagedBlock, body: string): string;
125
125
  /** Write `content` to `absPath` only if no file exists there. Returns whether
126
- * bytes were written. Parent dirs are NOT created (orchestrator's job). */
127
- declare function skipIfExists(absPath: string, content: string): WriteOutcome;
126
+ * bytes were written. Parent dirs are created by {@link atomicWriteFile}.
127
+ *
128
+ * `fileMode` (slice E, §8.1) is the permission for the NEWLY created file —
129
+ * `0o600` for the `.noir/.env` credential seed. It is applied to the temp file
130
+ * BEFORE the rename (see `AtomicWriteOptions.mode`), so the file never exists
131
+ * at its final path with a laxer mode. It is deliberately ignored when the
132
+ * target already exists: this writer's whole contract is "never touch an
133
+ * existing file", and that includes its permissions. POSIX-only — Windows
134
+ * permissions are ACL-based and the mode is dropped there.
135
+ *
136
+ * The write goes through `atomicWriteFile` (tmp + rename) rather than a bare
137
+ * `writeFileSync`: a requested mode must land on the temp, and an interrupted
138
+ * seed write should not leave a half-written `.noir/.env` behind. Byte output
139
+ * is unchanged for every existing caller (same content, umask default 0o644
140
+ * when no mode is requested). */
141
+ declare function skipIfExists(absPath: string, content: string, fileMode?: number): WriteOutcome;
128
142
 
129
143
  /**
130
144
  * Declarative scaffold manifest — the single source of truth for what
@@ -172,6 +186,14 @@ interface ManifestEntry {
172
186
  template?: string;
173
187
  /** One-line human description for `noir doctor` + logs. */
174
188
  description?: string;
189
+ /** Permission for a NEWLY created file (e.g. `0o600` for a credential seed).
190
+ * Applied only on creation; an existing file's mode is never changed — the
191
+ * `skipIfExists` contract means an existing file is not even opened. The
192
+ * orchestrator forwards this to the writer and reports it on the result as
193
+ * {@link ScaffoldResult.fileModes}. Ignored by every other write mode, whose
194
+ * targets are regenerated rather than seeded.
195
+ * NOTE: POSIX-only. Windows permissions are ACL-based and ignore it. */
196
+ fileMode?: number;
175
197
  /** Required for `mergeJson` mode: the JSON patch object (or a template that
176
198
  * renders to one). Merged into the existing file, preserving user keys. */
177
199
  patch?: Record<string, unknown>;
@@ -232,6 +254,8 @@ declare const MANIFEST_PATH_PARITY: ReadonlyArray<[
232
254
  * chosen host for `noir sync` to read back.)
233
255
  * - `NOIR.md` → managedBlock (BRIEF_BLOCK). Auto-brief is co-owned.
234
256
  * - `RULES.md` → skipIfExists. User-owned working-contract seed.
257
+ * - `README.md` → managedBlock (README_BLOCK). The `.noir/` runtime map is
258
+ * regenerated as the layout grows; user notes outside the markers survive.
235
259
  * - ignore files → managedBlock (IGNORE_BLOCK). Matches syncIgnores.
236
260
  * - host entries → SEE {@link buildHostArtifacts} (regenerate / managedBlock).
237
261
  */
@@ -361,10 +385,10 @@ declare function mergeThreeWay(base: string, ours: string, theirs: string, style
361
385
  * `result.conflicts` so the caller (doctor/CI) can fail loudly with a
362
386
  * reviewable artifact rather than hang on a prompt.
363
387
  *
364
- * The registry ({@link MIGRATIONS}) is the deliverable at v1.0.0 — there are no
365
- * real version-to-version migrations yet. The synthetic `1.0.0 → 1.0.0`
366
- * migration exists to exercise the runner end-to-end and to give a template
367
- * for the first real migration.
388
+ * The registry ({@link MIGRATIONS}) carries two entries: the synthetic
389
+ * `1.0.0 → 1.0.0` smoke test that exercises the runner end-to-end, and the
390
+ * first real version-to-version migration, `1.0.0 → 1.1.0`. The synthetic one
391
+ * is the copy-pasteable template for the next contributor.
368
392
  */
369
393
  interface MigrationContext {
370
394
  /** Absolute repo root the migration operates on. */
@@ -419,6 +443,8 @@ declare function runMigrations(root: string, from: string | null, to: string, op
419
443
  ran: string[];
420
444
  };
421
445
 
446
+ /** The registry. The runner sorts the selected window by `to`, so declaration
447
+ * order is documentation only — oldest step first. */
422
448
  declare const MIGRATIONS: readonly MigrationScript[];
423
449
  /** Write git-style inline conflict markers around `theirs`/`ours` so a human
424
450
  * or AI agent can resolve later. This is the CI-safe fallback the spec locks
@@ -467,9 +493,12 @@ interface ScaffoldOptions {
467
493
  /** Explicit project id; bypasses generate/read. Mainly for tests + `create`
468
494
  * flows that want deterministic ids. */
469
495
  projectId?: string;
470
- /** `noir init --upgrade`: run migrations before re-emitting, and emit only
471
- * regenerate + managedBlock (skipIfExists left alone). Only meaningful
472
- * with mode `'init'`. */
496
+ /** `noir init --upgrade`: run migrations, then re-emit every manifest mode
497
+ * EXCEPT `mergeJson` (`.claude/settings.local.json` is user-owned and
498
+ * write-once at init/create). `skipIfExists` keeps its
499
+ * create-only-if-absent semantics, so an upgrade backfills seeds added since
500
+ * initialization without ever touching a file the user owns. Only meaningful
501
+ * with mode `'init'`. */
473
502
  upgrade?: boolean;
474
503
  /** SP-A: re-scaffold even when the target is already initialized (bypasses
475
504
  * the already-initialized no-op guard). Does NOT bypass `assertSafeRoot`
@@ -535,6 +564,15 @@ interface ScaffoldResult {
535
564
  * grepping stderr. Empty when no conflicts occurred (also on first-run +
536
565
  * no-op). */
537
566
  conflicts: ConflictRecord[];
567
+ /** Permission planned for each file this run CREATED at a non-default mode
568
+ * (repo-relative path → mode), i.e. the {@link ManifestEntry.fileMode} of a
569
+ * `skipIfExists` entry that had no file on disk. A real run reports the mode
570
+ * it applied; a `dryRun` reports the mode it WOULD apply (the predictor's
571
+ * job — the caller can preview `.noir/.env` at 0600 without writing it).
572
+ * Paths the entry skipped (already present) are absent: an existing file's
573
+ * mode is never changed. Optional so existing external constructors of this
574
+ * type (test stubs) stay valid; `scaffold()` always populates it. */
575
+ fileModes?: Record<string, number>;
538
576
  }
539
577
  /** One entry in {@link ScaffoldResult.conflicts}. */
540
578
  interface ConflictRecord {
@@ -614,7 +652,7 @@ declare function scaffold(opts: ScaffoldOptions): Promise<ScaffoldResult>;
614
652
  */
615
653
  /** The scaffold version this build of @noir-ai/create ships. Bumped atomically
616
654
  * whenever a manifest entry, template, or migration changes shape. */
617
- declare const CURRENT_SCAFFOLD_VERSION = "1.0.0";
655
+ declare const CURRENT_SCAFFOLD_VERSION = "1.1.0";
618
656
  /** Path to the stamp file under `root`. Exposed for tests + doctor. */
619
657
  declare function scaffoldVersionPath(root: string): string;
620
658
  /** Read the applied scaffold version, or `null` if the stamp is absent/unparseable.
package/dist/index.js CHANGED
@@ -47,12 +47,15 @@ import {
47
47
  RULES_BLOCK
48
48
  } from "@noir-ai/core";
49
49
  var BRIEF_BLOCK = managedBlock("brief", "html");
50
+ var README_BLOCK = managedBlock("readme", "html");
50
51
  var P = {
51
52
  projectId: `${NOIR_DIR}/project.id`,
52
53
  config: `${NOIR_DIR}/config.yml`,
53
54
  envExample: `${NOIR_DIR}/.env.example`,
55
+ env: `${NOIR_DIR}/.env`,
54
56
  noirMd: `${NOIR_DIR}/NOIR.md`,
55
- rulesMd: `${NOIR_DIR}/rules/RULES.md`
57
+ rulesMd: `${NOIR_DIR}/rules/RULES.md`,
58
+ readme: `${NOIR_DIR}/README.md`
56
59
  };
57
60
  var MANIFEST_PATH_PARITY = [
58
61
  [P.projectId, paths.projectId],
@@ -82,7 +85,20 @@ function hostAgnosticEntries(ctx) {
82
85
  path: P.envExample,
83
86
  mode: "skipIfExists",
84
87
  template: "env.example.tmpl",
85
- description: ".noir/.env placeholder (gitignored; copy + fill real tokens)"
88
+ description: ".noir/.env.example committable documentation (never loaded)"
89
+ },
90
+ {
91
+ // §8.1: init creates the REAL env file, not just the example. The body is
92
+ // all-comment, so the file parses to an EMPTY overlay and creating it
93
+ // changes no behaviour — it exists so the user never has to copy the
94
+ // example by hand. `fileMode: 0o600` because the file holds tokens the
95
+ // moment the user edits it (set on creation only — skipIfExists never
96
+ // touches an existing file, so a user's own chmod stands).
97
+ path: P.env,
98
+ mode: "skipIfExists",
99
+ template: "config.env.tmpl",
100
+ fileMode: 384,
101
+ description: "project-scoped env file (gitignored, 0600)"
86
102
  },
87
103
  {
88
104
  path: P.noirMd,
@@ -97,6 +113,18 @@ function hostAgnosticEntries(ctx) {
97
113
  template: "rules-seed.md.tmpl",
98
114
  description: "AI working-rules seed"
99
115
  },
116
+ {
117
+ // Slice E (spec §9): the `.noir/` runtime map — what init just wrote,
118
+ // what appears later (and which command creates it), and where to go
119
+ // next. Host-agnostic: it describes the canonical store, which every
120
+ // host shares. Co-owned (README_BLOCK) so user notes survive while the
121
+ // map stays current through `noir sync` / `init --upgrade`.
122
+ path: P.readme,
123
+ mode: "managedBlock",
124
+ block: README_BLOCK,
125
+ template: "noir-readme.md.tmpl",
126
+ description: ".noir/ runtime map (what exists now / what appears later)"
127
+ },
100
128
  // --- ignore files (host-agnostic; co-owned via IGNORE_BLOCK) ------------
101
129
  {
102
130
  path: ".gitignore",
@@ -505,7 +533,68 @@ var synthetic = {
505
533
  return result;
506
534
  }
507
535
  };
508
- var MIGRATIONS = [synthetic];
536
+ var ENV_POINTER_MARKER = "# noir:env-pointer";
537
+ var ENV_POINTER_BLOCK = [
538
+ ENV_POINTER_MARKER,
539
+ "# This file is safe to commit: it holds no secrets.",
540
+ "# Project-scoped values and secrets belong in .noir/.env (gitignored, mode",
541
+ "# 0600). .noir/.env.example documents the full variable set; `noir env`",
542
+ "# reports which source wins for each key.",
543
+ ""
544
+ ].join("\n");
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}
550
+
551
+ ${block}`;
552
+ }
553
+ var envPointer = {
554
+ from: "1.0.0",
555
+ to: "1.1.0",
556
+ description: "point .noir/config.yml at .noir/.env (the secrets home)",
557
+ run: (ctx) => {
558
+ const result = { changed: [], conflicts: [], notes: [] };
559
+ const rel = ".noir/config.yml";
560
+ const abs = join3(ctx.root, ".noir", "config.yml");
561
+ if (!existsSync3(abs)) {
562
+ result.notes.push(`${rel}: absent \u2014 the template seed carries the pointer`);
563
+ return result;
564
+ }
565
+ let prev;
566
+ try {
567
+ prev = readFileSync2(abs, "utf8");
568
+ } catch (err) {
569
+ result.conflicts.push(rel);
570
+ result.notes.push(`${rel}: unreadable (${errorMessage(err)}) \u2014 left untouched`);
571
+ return result;
572
+ }
573
+ if (prev.includes(ENV_POINTER_MARKER)) {
574
+ result.notes.push(`${rel}: already carries ${ENV_POINTER_MARKER}`);
575
+ return result;
576
+ }
577
+ if (ctx.dryRun) {
578
+ result.changed.push(rel);
579
+ result.notes.push(`${rel}: would append the .noir/.env pointer`);
580
+ return result;
581
+ }
582
+ try {
583
+ writeFileSync2(abs, appendSeparated(prev, ENV_POINTER_BLOCK), "utf8");
584
+ } catch (err) {
585
+ result.conflicts.push(rel);
586
+ result.notes.push(`${rel}: write failed (${errorMessage(err)}) \u2014 left untouched`);
587
+ return result;
588
+ }
589
+ result.changed.push(rel);
590
+ result.notes.push(`${rel}: appended the .noir/.env pointer`);
591
+ return result;
592
+ }
593
+ };
594
+ var MIGRATIONS = [synthetic, envPointer];
595
+ function errorMessage(err) {
596
+ return err instanceof Error ? err.message : String(err);
597
+ }
509
598
  function applyInlineConflict(ours, theirs, oursLabel = "ours", theirsLabel = "theirs") {
510
599
  return `<<<<<<< ${oursLabel}
511
600
  ${ours}=======
@@ -548,7 +637,7 @@ import {
548
637
  writeSync
549
638
  } from "fs";
550
639
  import { basename, dirname as dirname2, join as join4 } from "path";
551
- import { stripManagedBlock, writeManagedRegion } from "@noir-ai/core";
640
+ import { atomicWriteFile, stripManagedBlock, writeManagedRegion } from "@noir-ai/core";
552
641
  function regenerate(absPath, content) {
553
642
  const dir = dirname2(absPath);
554
643
  const tmp = join4(
@@ -635,11 +724,11 @@ function predictManagedBlocks(currentContent, regions) {
635
724
 
636
725
  ${regionsJoined}` : regionsJoined;
637
726
  }
638
- function skipIfExists(absPath, content) {
727
+ function skipIfExists(absPath, content, fileMode) {
639
728
  if (existsSync4(absPath)) {
640
729
  return { path: absPath, mode: "skipIfExists", written: false };
641
730
  }
642
- writeFileSync3(absPath, content, "utf8");
731
+ atomicWriteFile(absPath, content, fileMode !== void 0 ? { mode: fileMode } : {});
643
732
  return { path: absPath, mode: "skipIfExists", written: true };
644
733
  }
645
734
  function mergeJson(absPath, patch, dedupSubstring) {
@@ -689,7 +778,7 @@ function deepMergePreservingHooks(existing, patch, dedupSubstring) {
689
778
  }
690
779
 
691
780
  // src/scaffold-version.ts
692
- var CURRENT_SCAFFOLD_VERSION = "1.0.0";
781
+ var CURRENT_SCAFFOLD_VERSION = "1.1.0";
693
782
  var PREFIX = "noir-scaffold=";
694
783
  function scaffoldVersionPath(root) {
695
784
  return join5(root, NOIR_DIR2, "scaffold-version");
@@ -931,17 +1020,11 @@ function templatesDir() {
931
1020
  }
932
1021
 
933
1022
  // src/scaffold.ts
934
- var WRITER_BY_MODE = {
935
- // 'runtime' subset = regenerate + managedBlock (the always-safe-to-rewrite
936
- // entries). sync + init --upgrade emit only this subset; skipIfExists is
937
- // reserved for first-run init/create so user edits survive.
938
- // 'mergeJson' (C3 SessionStart hook) is 'all' — the settings entry is
939
- // written once at init/create; sync re-emits only the script + router
940
- // contract (regenerate + managedBlock), never the user-owned entry.
941
- regenerate: "runtime",
942
- managedBlock: "runtime",
943
- skipIfExists: "all",
944
- mergeJson: "all"
1023
+ var EMITTED_IN = {
1024
+ regenerate: { sync: true, upgrade: true },
1025
+ managedBlock: { sync: true, upgrade: true },
1026
+ skipIfExists: { sync: false, upgrade: true },
1027
+ mergeJson: { sync: false, upgrade: false }
945
1028
  };
946
1029
  function assertSafeRoot(root) {
947
1030
  let cur = root;
@@ -989,6 +1072,7 @@ async function scaffold(opts) {
989
1072
  skipped: [],
990
1073
  identical: [],
991
1074
  conflicts: [],
1075
+ fileModes: {},
992
1076
  noop: true,
993
1077
  migrationsRan: [],
994
1078
  migrationConflicts: [],
@@ -1018,7 +1102,7 @@ async function scaffold(opts) {
1018
1102
  command,
1019
1103
  stack
1020
1104
  });
1021
- const emitRuntimeOnly = opts.mode === "sync" || opts.mode === "init" && opts.upgrade === true;
1105
+ const emitContext = opts.mode === "sync" ? "sync" : opts.mode === "init" && opts.upgrade === true ? "upgrade" : null;
1022
1106
  const vars = {
1023
1107
  root: opts.root,
1024
1108
  projectId,
@@ -1030,6 +1114,7 @@ async function scaffold(opts) {
1030
1114
  const written = [];
1031
1115
  const skipped = [];
1032
1116
  const identical = [];
1117
+ const fileModes = {};
1033
1118
  const conflictMemory = /* @__PURE__ */ new Map();
1034
1119
  const conflictRecords = [];
1035
1120
  const recordConflict = (relPath, mode, existing, proposed, resolution, similarity2) => {
@@ -1042,7 +1127,7 @@ async function scaffold(opts) {
1042
1127
  resolution
1043
1128
  });
1044
1129
  };
1045
- const groups = groupApplicableByPath(manifest, host, emitRuntimeOnly);
1130
+ const groups = groupApplicableByPath(manifest, host, emitContext);
1046
1131
  for (const [relPath, entries] of groups) {
1047
1132
  const abs = join8(opts.root, relPath);
1048
1133
  const managed = entries.filter((e) => e.mode === "managedBlock");
@@ -1095,8 +1180,16 @@ async function scaffold(opts) {
1095
1180
  if (!opts.dryRun) mkdirSync3(dirname5(abs), { recursive: true });
1096
1181
  for (const entry of entries) {
1097
1182
  if (opts.dryRun) {
1098
- if (entry.mode === "skipIfExists" && existsSync6(abs)) skipped.push(entry.path);
1099
- else written.push(entry.path);
1183
+ if (entry.mode === "skipIfExists") {
1184
+ if (existsSync6(abs)) {
1185
+ skipped.push(entry.path);
1186
+ } else {
1187
+ written.push(entry.path);
1188
+ if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
1189
+ }
1190
+ } else {
1191
+ written.push(entry.path);
1192
+ }
1100
1193
  continue;
1101
1194
  }
1102
1195
  const body = renderEntry(entry, vars);
@@ -1156,13 +1249,17 @@ async function scaffold(opts) {
1156
1249
  if (out.written) written.push(entry.path);
1157
1250
  else identical.push(entry.path);
1158
1251
  } else {
1159
- const out = skipIfExists(abs, body);
1160
- if (out.written) written.push(entry.path);
1161
- else skipped.push(entry.path);
1252
+ const out = skipIfExists(abs, body, entry.fileMode);
1253
+ if (out.written) {
1254
+ written.push(entry.path);
1255
+ if (entry.fileMode !== void 0) fileModes[entry.path] = entry.fileMode;
1256
+ } else {
1257
+ skipped.push(entry.path);
1258
+ }
1162
1259
  }
1163
1260
  }
1164
1261
  }
1165
- if ((opts.mode === "init" || opts.mode === "create") && !opts.dryRun) {
1262
+ if ((opts.mode === "init" || opts.mode === "create") && !opts.dryRun && migrationConflicts.length === 0) {
1166
1263
  writeScaffoldVersion(opts.root, CURRENT_SCAFFOLD_VERSION);
1167
1264
  }
1168
1265
  if (!opts.dryRun) {
@@ -1173,6 +1270,7 @@ async function scaffold(opts) {
1173
1270
  skipped,
1174
1271
  identical,
1175
1272
  conflicts: conflictRecords,
1273
+ fileModes,
1176
1274
  noop: false,
1177
1275
  migrationsRan,
1178
1276
  migrationConflicts,
@@ -1316,11 +1414,11 @@ function uniqueAside(abs, relPath, suffix) {
1316
1414
  for (let n = 1; existsSync6(candidate.abs); n++) candidate = make(`${suffix}.${n}`);
1317
1415
  return candidate;
1318
1416
  }
1319
- function groupApplicableByPath(manifest, host, emitRuntimeOnly) {
1417
+ function groupApplicableByPath(manifest, host, emitContext) {
1320
1418
  const groups = /* @__PURE__ */ new Map();
1321
1419
  for (const entry of manifest) {
1322
1420
  if (entry.host !== void 0 && entry.host !== host) continue;
1323
- if (emitRuntimeOnly && WRITER_BY_MODE[entry.mode] !== "runtime") continue;
1421
+ if (emitContext !== null && !EMITTED_IN[entry.mode][emitContext]) continue;
1324
1422
  const list = groups.get(entry.path);
1325
1423
  if (list) list.push(entry);
1326
1424
  else groups.set(entry.path, [entry]);