@stonepandastudio/cairn 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +60 -23
  2. package/bin/cairn.js +13 -5
  3. package/lib/doctor/index.js +6 -1
  4. package/lib/init.js +15 -1
  5. package/lib/manifest.js +33 -0
  6. package/lib/render/cli.js +116 -0
  7. package/lib/render/engine.js +148 -0
  8. package/lib/render/index.js +220 -0
  9. package/lib/sync/cli.js +197 -0
  10. package/lib/sync/index.js +249 -0
  11. package/package.json +2 -1
  12. package/presets/EXTRACTION.md +210 -0
  13. package/presets/README.md +86 -0
  14. package/presets/angular/code-guidelines.md +197 -0
  15. package/presets/angular/slots/architect-discussion-topics.md +10 -0
  16. package/presets/angular/slots/architect-mandatory-docs.md +5 -0
  17. package/presets/angular/slots/architect-references.md +4 -0
  18. package/presets/angular/slots/implementation-reference.md +34 -0
  19. package/presets/angular/slots/key-patterns.md +11 -0
  20. package/presets/angular/slots/plan-step-ordering.md +11 -0
  21. package/presets/angular/slots/review-checklist.md +16 -0
  22. package/presets/angular/variants/i18n-external-service.md +11 -0
  23. package/presets/angular/variants/i18n-glossr.md +63 -0
  24. package/presets/core/AGENTS.md +49 -0
  25. package/presets/core/README.md +35 -0
  26. package/presets/core/WORKFLOW.md +56 -0
  27. package/presets/core/agents/architect.md +269 -0
  28. package/presets/core/agents/developer.md +145 -0
  29. package/presets/core/agents/reviewer.md +167 -0
  30. package/presets/core/commands/_stub.md +7 -0
  31. package/presets/core/workflow.json +45 -0
  32. package/presets/drizzle/code-guidelines.md +33 -0
  33. package/presets/drizzle/slots/architect-discussion-topics.md +4 -0
  34. package/presets/drizzle/slots/architect-mandatory-docs.md +4 -0
  35. package/presets/drizzle/slots/implementation-reference.md +17 -0
  36. package/presets/drizzle/slots/key-patterns.md +7 -0
  37. package/presets/drizzle/slots/review-checklist.md +10 -0
  38. package/presets/nestjs/code-guidelines.md +273 -0
  39. package/presets/nestjs/slots/architect-discussion-topics.md +4 -0
  40. package/presets/nestjs/slots/architect-mandatory-docs.md +5 -0
  41. package/presets/nestjs/slots/architect-references.md +5 -0
  42. package/presets/nestjs/slots/implementation-reference.md +45 -0
  43. package/presets/nestjs/slots/key-patterns.md +11 -0
  44. package/presets/nestjs/slots/plan-step-ordering.md +12 -0
  45. package/presets/nestjs/slots/review-checklist.md +12 -0
  46. package/presets/nestjs/variants/validation-class-validator.md +120 -0
  47. package/presets/nestjs/variants/validation-zod.md +194 -0
  48. package/presets/nextjs/code-guidelines.md +45 -0
  49. package/presets/nextjs/slots/architect-discussion-topics.md +5 -0
  50. package/presets/nextjs/slots/architect-mandatory-docs.md +3 -0
  51. package/presets/nextjs/slots/architect-references.md +6 -0
  52. package/presets/nextjs/slots/implementation-reference.md +24 -0
  53. package/presets/nextjs/slots/key-patterns.md +8 -0
  54. package/presets/nextjs/slots/plan-step-ordering.md +11 -0
  55. package/presets/nextjs/slots/review-checklist.md +11 -0
  56. package/presets/react/code-guidelines.md +46 -0
  57. package/presets/react/slots/architect-discussion-topics.md +5 -0
  58. package/presets/react/slots/architect-references.md +5 -0
  59. package/presets/react/slots/implementation-reference.md +26 -0
  60. package/presets/react/slots/key-patterns.md +8 -0
  61. package/presets/react/slots/plan-step-ordering.md +9 -0
  62. package/presets/react/slots/review-checklist.md +10 -0
  63. package/presets/tailwind/code-guidelines.md +28 -0
  64. package/presets/tailwind/slots/implementation-reference.md +8 -0
  65. package/presets/tailwind/slots/key-patterns.md +5 -0
  66. package/presets/tailwind/slots/review-checklist.md +8 -0
  67. package/presets/typeorm/code-guidelines.md +329 -0
  68. package/presets/typeorm/slots/architect-discussion-topics.md +4 -0
  69. package/presets/typeorm/slots/architect-mandatory-docs.md +3 -0
  70. package/presets/typeorm/slots/implementation-reference.md +19 -0
  71. package/presets/typeorm/slots/key-patterns.md +8 -0
  72. package/presets/typeorm/slots/review-checklist.md +8 -0
package/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  Shared AI workflow scaffolding, kept in sync across several repos.
4
4
 
5
5
  Each repo carries an `ai/` folder and a `.claude/commands/` folder holding the same
6
- workflow — brief → description → plan → execute → review → QA, with the steps
7
- mirrored into an issue tracker. Copied between repos by hand, they drift.
6
+ workflow — brief → description → plan → execute → review, with the steps optionally
7
+ mirrored into an issue tracker. Hand-copied between repos, they drift. cairn renders
8
+ them from one set of presets and reports where existing copies disagree.
8
9
 
9
10
  A cairn is a stack of stones left to mark a trail. That is what this does: it leaves
10
11
  markers — a manifest of what it generated, and a report of where the copies stopped
@@ -15,33 +16,63 @@ npm i -D @stonepandastudio/cairn
15
16
  npx cairn init --stack nestjs,typeorm --tracker jira-server --project-key MYPROJ --story-type Task
16
17
  ```
17
18
 
18
- ## What v1 does
19
+ ## What it does
19
20
 
20
21
  | Command | |
21
22
  |---|---|
22
23
  | `cairn tracker <cmd>` | Issue tracker client (`jira-server` / `youtrack` / `none`). Replaces the copied `ai/scripts/*.js` clients |
23
24
  | `cairn doctor` | Cross-repo drift report, plus per-repo generated-file state |
24
25
  | `cairn init` | Makes a repo cairn-managed: writes `cairn.config.json`, `_cairn/`, and the shim |
26
+ | `cairn render` | Scaffolds `ai/` + `.claude/commands/` from the bundled presets, from the repo's stack + workflow |
27
+ | `cairn sync` | Re-renders and three-way merges preset changes into a repo that has already rendered |
25
28
 
26
- Template rendering generating the command stubs, `WORKFLOW.md` and the agent role
27
- contracts from one workflow declaration is v2, and deliberately not in this release.
28
- v1 swaps the tracker client under the consuming repos; bundling prompt changes into
29
- the same release would make any regression impossible to attribute.
29
+ `cairn render` is **greenfield only**: it writes files that do not exist, records them
30
+ in the manifest, stores the render in `_cairn/base/`, and leaves anything already on
31
+ disk alone (reporting it).
32
+
33
+ `cairn sync` is the upgrade path. For each managed file it holds three versions —
34
+ `base` (the last render, in `_cairn/base/`), `ours` (what is on disk), `theirs` (what
35
+ the current presets render) — and runs a 2×2:
36
+
37
+ | | template unchanged | template changed |
38
+ |---|---|---|
39
+ | **local unchanged** | no-op | fast-forward — write the new render |
40
+ | **local changed** | keep local (`MODIFIED`) | three-way merge |
41
+
42
+ Three cells are automatic. The merge shells out to `git merge-file`; a clean result
43
+ is written, a conflicted one is written **with markers** and the command exits
44
+ non-zero (`CONFLICT`, a fifth manifest state). Nothing is ever silently overwritten.
45
+ Resolve the markers and run `cairn sync` again — the base has already advanced, so
46
+ the second run is a quiet `MODIFIED`.
47
+
48
+ ```bash
49
+ cairn sync --dry-run # show the outcome per file, write nothing
50
+ cairn sync # apply; exit 3 if any file is left in conflict
51
+ cairn sync --adopt-base # for files rendered before _cairn/base/ existed
52
+ ```
30
53
 
31
54
  ## The two folders
32
55
 
33
56
  ```
34
57
  repo/
35
- cairn.config.json you edit this. cairn only ever reads it.
36
- _cairn/ cairn writes this. nobody hand-edits it.
37
- manifest.json what was generated, from which template, at which hash
38
- scripts/jira.js vendored shim
39
- ai/ project-owned. cairn never touches it.
40
- contexts/ progress/ tasks/ infrastructure/
58
+ cairn.config.json you edit this. cairn only ever reads it.
59
+ _cairn/ cairn writes this. nobody hand-edits it.
60
+ manifest.json what was generated, from which template, at which hash
61
+ base/ the last render of every managed file — `cairn sync`'s merge base
62
+ scripts/jira.js vendored tracker shim
63
+ .claude/commands/ rendered from the workflow — one stub per step
64
+ ai/
65
+ agents/ rendered from presets/core + your stack's slots
66
+ AGENTS.md WORKFLOW.md rendered
67
+ infrastructure/code-guidelines.md rendered once as a seed — then it is yours
68
+ contexts/ tasks/ yours, never written by cairn
41
69
  ```
42
70
 
43
- The boundary is the whole design. Hand-edited config living inside a generated
44
- directory is how generated directories acquire state nobody dares regenerate.
71
+ `cairn render` seeds the rendered files once and records them in the manifest; it
72
+ never rewrites a file you have edited. `cairn sync` is what re-renders later preset
73
+ changes over your edits, three-way merging where both moved. The boundary is the
74
+ whole design: hand-edited config living inside a generated directory is how
75
+ generated directories acquire state nobody dares regenerate.
45
76
 
46
77
  Generated files are **committed**, not gitignored. Claude Code discovers
47
78
  `.claude/commands/` and agent docs from disk, and a prompt change should show up in
@@ -88,7 +119,7 @@ environment wins over the file. If the repo's own app reads those same names, se
88
119
  `create-task`, `create-story`, `close-story` and friends are all still accepted, and
89
120
  output formats are byte-for-byte what the old scripts printed. Existing prose
90
121
  references name these strings and the agents parse the output, so they are kept
91
- until the v2 pass regenerates those documents.
122
+ until a render pass regenerates those documents from the presets.
92
123
 
93
124
  ## Doctor
94
125
 
@@ -124,11 +155,13 @@ match what is on disk?
124
155
  |---|---|
125
156
  | `MANAGED` | on-disk hash matches what cairn wrote |
126
157
  | `MODIFIED` | someone hand-edited a generated file |
127
- | `OUTDATED` | cairn has newer content for it — needs template hashes, wired in v2 |
128
- | `DELETED` | it is gone; a future `cairn sync` will restore it |
158
+ | `OUTDATED` | cairn has newer content for it — needs template hashes threaded through |
159
+ | `DELETED` | it is gone; `cairn sync` restores it from the current render |
160
+ | `CONFLICT` | `cairn sync` left git merge markers in it — resolve, then sync again |
129
161
 
130
- v1 reports `MANAGED`, `MODIFIED` and `DELETED`. `OUTDATED` and the `sync` that acts
131
- on these states arrive with the v2 renderer.
162
+ Today the doctor reports `MANAGED`, `MODIFIED`, `DELETED` and `CONFLICT`. `OUTDATED`
163
+ (needs the template hashes threaded through) is still to come. Acting on drift
164
+ between the render and the working tree is `cairn sync`.
132
165
 
133
166
  A file can be perfectly in sync across repos while being hand-edited away from its
134
167
  template, and vice versa. `MODIFIED` outranks `OUTDATED` in the report because
@@ -152,8 +185,8 @@ overwriting a hand edit is the destructive outcome.
152
185
  `ai/INITIAL_PROMPT.md` is explicitly excluded: variants at ~0% shared content make
153
186
  it a per-project document, not a drifted template.
154
187
 
155
- v2 splits this flat file into a per-project `workspace.json` plus an opt-in hub that
156
- references the projects for cross-project comparison. See `DESIGN.md`.
188
+ A later release splits this flat file into a per-project `workspace.json` plus an
189
+ opt-in hub that references the projects for cross-project comparison. See `DESIGN.md`.
157
190
 
158
191
  ## Layout
159
192
 
@@ -164,10 +197,14 @@ lib/manifest.js generated-file hashing and state
164
197
  lib/paint.js ANSI + table rendering
165
198
  lib/tracker/ index (registry), cli, jira-server, youtrack, none
166
199
  lib/doctor/ index (analysis + report), scan, normalize, diff
200
+ lib/render/ engine (a small template engine), index (driver), cli
201
+ lib/sync/ index (the 2x2 + git merge-file), cli
167
202
  lib/init.js cairn init
168
203
  templates/shims/ vendored shim source
204
+ presets/ core/ + framework/ORM/styling presets rendered by `cairn render`
169
205
  schema.json JSON Schema for cairn.config.json
170
206
  test/run.js dependency-free test runner
171
207
  ```
172
208
 
173
- No runtime dependencies. Node >= 18. `npm test` runs 56 tests.
209
+ No runtime dependencies (`cairn sync` shells out to `git`). Node >= 18. `npm test`
210
+ runs 74 tests.
package/bin/cairn.js CHANGED
@@ -3,14 +3,12 @@
3
3
 
4
4
  // cairn — shared AI workflow scaffolding for Stone Panda repos.
5
5
  //
6
- // v1 surface:
6
+ // Surface:
7
7
  // cairn tracker <cmd> issue tracker client (replaces the copied ai/scripts/jira.js)
8
8
  // cairn doctor cross-repo drift report
9
9
  // cairn init make a repo cairn-managed
10
- //
11
- // Template rendering (command stubs, WORKFLOW.md, agent role contracts) is v2 and
12
- // deliberately absent: this release swaps the Jira client under five repos, and
13
- // mixing that with prompt changes would make any regression unattributable.
10
+ // cairn render scaffold ai/ from the bundled presets (greenfield)
11
+ // cairn sync three-way merge of preset changes into a rendered repo
14
12
 
15
13
  const HELP = `cairn — shared AI workflow scaffolding
16
14
 
@@ -19,6 +17,8 @@ Usage: cairn <command> [options]
19
17
  tracker <cmd> [...] issue tracker client (cairn tracker --help)
20
18
  doctor [options] cross-repo drift report (cairn doctor --help)
21
19
  init [options] make a repo cairn-managed (cairn init --help)
20
+ render [options] scaffold ai/ from the bundled presets (cairn render --help)
21
+ sync [options] merge preset changes into an already-rendered repo (cairn sync --help)
22
22
  version print version
23
23
 
24
24
  Run \`cairn <command> --help\` for command options.
@@ -40,6 +40,14 @@ function main() {
40
40
  process.exitCode = require('../lib/init').main(rest);
41
41
  return;
42
42
 
43
+ case 'render':
44
+ process.exitCode = require('../lib/render/cli').main(rest);
45
+ return;
46
+
47
+ case 'sync':
48
+ process.exitCode = require('../lib/sync/cli').main(rest);
49
+ return;
50
+
43
51
  case 'version':
44
52
  case '--version':
45
53
  case '-v':
@@ -40,6 +40,7 @@ const MANAGED_COLOR = {
40
40
  OUTDATED: 'yellow',
41
41
  MODIFIED: 'red',
42
42
  DELETED: 'magenta',
43
+ CONFLICT: 'red',
43
44
  };
44
45
 
45
46
  // ------------------------------------------------------------------ workspace
@@ -510,7 +511,11 @@ function main(argv = process.argv.slice(2)) {
510
511
  .flatMap((s) => s.files)
511
512
  .some((f) => f.status === 'DRIFT' || f.status === 'MISSING' || f.status === 'STRANDED');
512
513
  const broken = managed.some(
513
- (m) => m.error || m.files.some((f) => f.state === 'MODIFIED' || f.state === 'DELETED'),
514
+ (m) =>
515
+ m.error ||
516
+ m.files.some(
517
+ (f) => f.state === 'MODIFIED' || f.state === 'DELETED' || f.state === 'CONFLICT',
518
+ ),
514
519
  );
515
520
  if (drifted || broken) return 1;
516
521
  }
package/lib/init.js CHANGED
@@ -9,6 +9,7 @@ const {
9
9
  readManifest,
10
10
  record,
11
11
  writeManifest,
12
+ writeBase,
12
13
  manifestPath,
13
14
  hashContent,
14
15
  } = require('./manifest');
@@ -58,6 +59,7 @@ Usage: cairn init [options]
58
59
  (only when the repo's app reads JIRA_*/YOUTRACK_* itself)
59
60
  --no-shim do not vendor or replace the ai/scripts/*.js client shims
60
61
  --force overwrite an existing cairn.config.json
62
+ --render also scaffold ai/ from the presets after init
61
63
  --force-shims also replace an ai/scripts/*.js shim that was hand-edited
62
64
  since cairn wrote it (kept untouched otherwise)
63
65
  --dry-run print what would be written, write nothing
@@ -75,6 +77,7 @@ function parseArgs(argv) {
75
77
  shim: true,
76
78
  force: false,
77
79
  forceShims: false,
80
+ render: false,
78
81
  dryRun: false,
79
82
  color: process.stdout.isTTY,
80
83
  };
@@ -90,6 +93,7 @@ function parseArgs(argv) {
90
93
  else if (a === '--no-shim') args.shim = false;
91
94
  else if (a === '--force') args.force = true;
92
95
  else if (a === '--force-shims') args.forceShims = args.force = true;
96
+ else if (a === '--render') args.render = true;
93
97
  else if (a === '--dry-run') args.dryRun = true;
94
98
  else if (a === '--no-color') args.color = false;
95
99
  else if (a === '-h' || a === '--help') args.help = true;
@@ -207,7 +211,10 @@ function main(argv = process.argv.slice(2)) {
207
211
  fs.writeFileSync(abs, content, 'utf8');
208
212
  // cairn.config.json is hand-editable, so it is deliberately not manifested —
209
213
  // tracking it would report every legitimate edit as MODIFIED.
210
- if (source) record(manifest, rel, source, content);
214
+ if (source) {
215
+ record(manifest, rel, source, content);
216
+ writeBase(repoPath, rel, content);
217
+ }
211
218
  }
212
219
  writeManifest(repoPath, manifest);
213
220
 
@@ -233,6 +240,13 @@ function main(argv = process.argv.slice(2)) {
233
240
  console.log(paint.dim(`\n Next: ensure .env has ${envVars.join(', ')}, then run`));
234
241
  console.log(paint.dim(` npx cairn tracker list-statuses ${args.projectKey}`));
235
242
  }
243
+
244
+ if (args.render) {
245
+ console.log('');
246
+ require('./render/cli').renderInto(repoPath, { paint });
247
+ } else {
248
+ console.log(paint.dim(`\n Next: \`cairn render\` to scaffold ai/ from the presets`));
249
+ }
236
250
  return 0;
237
251
  }
238
252
 
package/lib/manifest.js CHANGED
@@ -21,11 +21,37 @@ const GENERATOR = require('../package.json').name;
21
21
  // MODIFIED on-disk hash !== written hash someone edited it by hand
22
22
  // OUTDATED written hash !== current template hash cairn has newer content
23
23
  // DELETED file is gone sync will restore it
24
+ // CONFLICT entry.conflict is set `cairn sync` left merge markers
25
+ //
26
+ // `_cairn/base/<relPath>` holds the bytes of the last clean template render for
27
+ // each managed file — the merge base `cairn sync` diffs against. The manifest
28
+ // hash is "what is on disk after the last cairn write"; the base file is "what
29
+ // the template produced". They diverge once a merge folds local edits back in.
24
30
 
25
31
  function manifestPath(repoPath) {
26
32
  return path.join(cairnDir(repoPath), MANIFEST_NAME);
27
33
  }
28
34
 
35
+ const BASE_DIR = 'base';
36
+
37
+ function basePath(repoPath, relPath) {
38
+ return path.join(cairnDir(repoPath), BASE_DIR, relPath.replace(/\\/g, '/'));
39
+ }
40
+
41
+ // The last clean template render for `relPath`. `cairn render` and `cairn init`
42
+ // write it alongside the real file; `cairn sync` reads it as the merge base and
43
+ // rewrites it on every fast-forward or clean merge.
44
+ function readBase(repoPath, relPath) {
45
+ const file = basePath(repoPath, relPath);
46
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
47
+ }
48
+
49
+ function writeBase(repoPath, relPath, content) {
50
+ const file = basePath(repoPath, relPath);
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ fs.writeFileSync(file, content, 'utf8');
53
+ }
54
+
29
55
  // Normalize before hashing so a CRLF checkout doesn't read as a hand edit. This
30
56
  // matters here specifically: these repos are Windows-side with mixed .gitattributes.
31
57
  function hashContent(text) {
@@ -83,6 +109,10 @@ function statusFor(repoPath, manifest, templateHashes = null) {
83
109
  continue;
84
110
  }
85
111
  const onDisk = hashContent(fs.readFileSync(abs, 'utf8'));
112
+ if (entry.conflict) {
113
+ results.push({ path: rel, source: entry.source, state: 'CONFLICT' });
114
+ continue;
115
+ }
86
116
  if (onDisk !== entry.hash) {
87
117
  results.push({ path: rel, source: entry.source, state: 'MODIFIED' });
88
118
  continue;
@@ -109,12 +139,15 @@ module.exports = {
109
139
  CAIRN_DIR,
110
140
  MANIFEST_NAME,
111
141
  MANIFEST_VERSION,
142
+ basePath,
112
143
  emptyManifest,
113
144
  hashContent,
114
145
  header,
115
146
  manifestPath,
147
+ readBase,
116
148
  readManifest,
117
149
  record,
118
150
  statusFor,
151
+ writeBase,
119
152
  writeManifest,
120
153
  };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { loadRepoConfig, findRepoRoot, ConfigError } = require('../config');
7
+ const {
8
+ readManifest,
9
+ emptyManifest,
10
+ record,
11
+ writeManifest,
12
+ writeBase,
13
+ manifestPath,
14
+ } = require('../manifest');
15
+ const { makePaint } = require('../paint');
16
+ const { planRender } = require('./index');
17
+
18
+ const PRESETS_DIR = path.join(__dirname, '..', '..', 'presets');
19
+
20
+ const HELP = `cairn render — scaffold a repo's ai/ from the bundled presets
21
+
22
+ Usage: cairn render [options]
23
+
24
+ --repo <path> repo to render into (default: the repo cairn.config.json is in)
25
+ --dry-run print what would be written, write nothing
26
+ --no-color disable ANSI colour
27
+
28
+ Reads the repo's cairn.config.json (run \`cairn init\` first) — the stack list,
29
+ tracker, and any agent overrides. Greenfield only: it writes files that do not
30
+ exist and records them in the manifest; anything already on disk is left alone.
31
+ `;
32
+
33
+ function parseArgs(argv) {
34
+ const args = { repo: null, dryRun: false, color: process.stdout.isTTY };
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ if (a === '--repo') args.repo = argv[++i];
38
+ else if (a === '--dry-run') args.dryRun = true;
39
+ else if (a === '--no-color') args.color = false;
40
+ else if (a === '-h' || a === '--help') args.help = true;
41
+ else {
42
+ console.error(`Unknown argument: ${a}`);
43
+ return { error: 2 };
44
+ }
45
+ }
46
+ return args;
47
+ }
48
+
49
+ // Shared by `cairn render` and `cairn init --render`.
50
+ function renderInto(repoPath, { dryRun = false, paint } = {}) {
51
+ const config = loadRepoConfig(repoPath, { required: true });
52
+ const manifest = readManifest(repoPath) || emptyManifest();
53
+ paint = paint || makePaint(false);
54
+
55
+ const { planned, kept } = planRender({ config, presetsDir: PRESETS_DIR, manifest, repoPath });
56
+
57
+ if (dryRun) {
58
+ console.log(paint.bold(`cairn render --dry-run ${repoPath}`));
59
+ for (const [rel, , , note] of planned) {
60
+ console.log(` ${paint.green('would write')} ${rel}${note ? paint.dim(` (${note})`) : ''}`);
61
+ }
62
+ for (const k of kept) console.log(paint.yellow(` would keep ${k.rel} (${k.reason})`));
63
+ if (!planned.length && !kept.length) console.log(paint.dim(' nothing to render'));
64
+ return 0;
65
+ }
66
+
67
+ let wrote = 0;
68
+ for (const [rel, content, source] of planned) {
69
+ const abs = path.join(repoPath, rel);
70
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
71
+ fs.writeFileSync(abs, content, 'utf8');
72
+ record(manifest, rel, source, content);
73
+ // The merge base `cairn sync` will diff against: what the template produced,
74
+ // verbatim, this run.
75
+ writeBase(repoPath, rel, content);
76
+ console.log(` ${paint.green('wrote')} ${rel}`);
77
+ wrote++;
78
+ }
79
+ if (wrote) {
80
+ writeManifest(repoPath, manifest);
81
+ console.log(` ${paint.green('wrote')} ${path.relative(repoPath, manifestPath(repoPath)).replace(/\\/g, '/')}`);
82
+ }
83
+ for (const k of kept) console.log(paint.yellow(` kept ${k.rel} (${k.reason})`));
84
+ if (!wrote && !kept.length) console.log(paint.dim(' nothing to render — all current'));
85
+ return 0;
86
+ }
87
+
88
+ function main(argv = process.argv.slice(2)) {
89
+ const args = parseArgs(argv);
90
+ if (args.error) return args.error;
91
+ if (args.help) {
92
+ process.stdout.write(HELP);
93
+ return 0;
94
+ }
95
+
96
+ const start = args.repo ? path.resolve(args.repo) : process.cwd();
97
+ const repoPath = args.repo ? start : findRepoRoot(start);
98
+ if (!repoPath || !fs.existsSync(path.join(repoPath, 'cairn.config.json'))) {
99
+ console.error(`No cairn.config.json found — run \`cairn init\` first`);
100
+ return 2;
101
+ }
102
+
103
+ const paint = makePaint(args.color);
104
+ try {
105
+ if (!args.dryRun) console.log(paint.bold(`cairn render ${repoPath}`));
106
+ return renderInto(repoPath, { dryRun: args.dryRun, paint });
107
+ } catch (e) {
108
+ if (e instanceof ConfigError) {
109
+ console.error(e.message);
110
+ return 2;
111
+ }
112
+ throw e;
113
+ }
114
+ }
115
+
116
+ module.exports = { main, renderInto, HELP, PRESETS_DIR };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ // A very small template engine — the subset of Handlebars the preset files use
4
+ // and nothing more. Pure: no fs, no cairn knowledge. The driver in ./index.js
5
+ // supplies the context and the `{{> stack/… }}` partial resolver.
6
+ //
7
+ // Supported:
8
+ // {{ path }} substitution, not HTML-escaped (output is markdown)
9
+ // {{ this.x }} `this` and dotted paths, plus @index / @index_1 / @first / @last inside #each
10
+ // {{#if path}}…{{/if}} with optional {{else}}
11
+ // {{#each path}}…{{/each}}
12
+ // {{> stack/name }} resolved by the caller-supplied resolvePartial(name, ctx)
13
+ // {{! comment }} dropped
14
+ // a leading <!-- cairn preset: … --> or <!-- cairn:… --> block is dropped
15
+
16
+ const TOKEN = /(\{\{!(?:(?!\}\})[\s\S])*\}\}|\{\{\{[^}]*\}\}\}|\{\{[#/>]?\s*[^}]*?\}\})/;
17
+
18
+ // Drop cairn's own editorial comments — `<!-- cairn preset: … -->` /
19
+ // `{{! … }}` — wherever they sit in a template. They document the preset for a
20
+ // human reading `presets/` and must not reach the rendered output. Then tidy the
21
+ // whitespace a block tag on its own line would otherwise leave behind (Mustache's
22
+ // "standalone" rule), and collapse the runs of blank lines that removing a
23
+ // comment block opens up.
24
+ function stripHeaderComment(src) {
25
+ return String(src)
26
+ .replace(/^/, '')
27
+ .replace(/[ \t]*<!--\s*cairn[:\s][\s\S]*?-->[ \t]*\n?/g, '')
28
+ .replace(/^[ \t]*\{\{!(?:(?!\}\})[\s\S])*\}\}[ \t]*\r?\n/gm, '')
29
+ .replace(/\{\{!(?:(?!\}\})[\s\S])*\}\}/g, '')
30
+ .replace(/^[ \t]*(\{\{[#/][^}]*\}\}|\{\{\s*else\s*\}\})[ \t]*\r?\n/gm, '$1')
31
+ .replace(/\n{3,}/g, '\n\n')
32
+ .replace(/^\n+/, '');
33
+ }
34
+
35
+ function tokenize(src) {
36
+ return src
37
+ .split(TOKEN)
38
+ .filter((s) => s !== '' && s !== undefined)
39
+ .map((s) => {
40
+ const m = s.match(/^\{\{([#/>!]?)\s*([\s\S]*?)\s*\}\}$/);
41
+ if (!m) return { t: 'text', v: s };
42
+ const [, sigil, body] = m;
43
+ if (sigil === '!') return { t: 'comment' };
44
+ if (sigil === '>') return { t: 'partial', v: body.trim() };
45
+ if (sigil === '#') {
46
+ const [kw, arg] = body.split(/\s+/, 2);
47
+ return { t: 'open', kw, arg };
48
+ }
49
+ if (sigil === '/') return { t: 'close', kw: body.trim() };
50
+ if (body === 'else') return { t: 'else' };
51
+ return { t: 'var', v: body.trim() };
52
+ });
53
+ }
54
+
55
+ // Build a nested node tree from the flat token list.
56
+ function parse(tokens) {
57
+ const root = { kw: 'root', body: [], else: null };
58
+ const stack = [root];
59
+ let target = 'body';
60
+
61
+ for (const tok of tokens) {
62
+ const top = stack[stack.length - 1];
63
+ const sink = target === 'else' ? top.else : top.body;
64
+
65
+ if (tok.t === 'comment') continue;
66
+ if (tok.t === 'text') sink.push({ t: 'text', v: tok.v });
67
+ else if (tok.t === 'var') sink.push({ t: 'var', v: tok.v });
68
+ else if (tok.t === 'partial') sink.push({ t: 'partial', v: tok.v });
69
+ else if (tok.t === 'open') {
70
+ const node = { t: 'block', kw: tok.kw, arg: tok.arg, body: [], else: [] };
71
+ sink.push(node);
72
+ stack.push(node);
73
+ target = 'body';
74
+ } else if (tok.t === 'else') {
75
+ target = 'else';
76
+ } else if (tok.t === 'close') {
77
+ if (stack.length < 2 || top.kw !== tok.kw) {
78
+ throw new Error(`template: unbalanced {{/${tok.kw}}}`);
79
+ }
80
+ stack.pop();
81
+ target = 'body';
82
+ }
83
+ }
84
+ if (stack.length !== 1) throw new Error(`template: unclosed {{#${stack[stack.length - 1].kw}}}`);
85
+ return root.body;
86
+ }
87
+
88
+ function lookup(ctx, path) {
89
+ if (path === 'this') return ctx.this;
90
+ if (path.startsWith('@')) return ctx['@'] ? ctx['@'][path] : undefined;
91
+ let cur = path.startsWith('this.') ? ctx.this : ctx;
92
+ for (const key of path.replace(/^this\./, '').split('.')) {
93
+ if (cur == null) return undefined;
94
+ cur = cur[key];
95
+ }
96
+ return cur;
97
+ }
98
+
99
+ function truthy(v) {
100
+ return Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null && v !== false && v !== '';
101
+ }
102
+
103
+ function renderNodes(nodes, ctx, resolvePartial) {
104
+ let out = '';
105
+ for (const node of nodes) {
106
+ if (node.t === 'text') {
107
+ out += node.v;
108
+ } else if (node.t === 'var') {
109
+ const v = lookup(ctx, node.v);
110
+ out += v === undefined || v === null ? '' : String(v);
111
+ } else if (node.t === 'partial') {
112
+ const resolved = resolvePartial(node.v, ctx);
113
+ out += resolved == null ? '' : renderNodes(parse(tokenize(stripHeaderComment(resolved))), ctx, resolvePartial);
114
+ } else if (node.t === 'block' && node.kw === 'if') {
115
+ const branch = truthy(lookup(ctx, node.arg)) ? node.body : node.else;
116
+ out += renderNodes(branch, ctx, resolvePartial);
117
+ } else if (node.t === 'block' && node.kw === 'each') {
118
+ const list = lookup(ctx, node.arg);
119
+ if (Array.isArray(list)) {
120
+ list.forEach((item, i) => {
121
+ const child = {
122
+ ...ctx,
123
+ this: item,
124
+ '@': {
125
+ '@index': i,
126
+ '@index_1': i + 1,
127
+ '@first': i === 0,
128
+ '@last': i === list.length - 1,
129
+ },
130
+ };
131
+ out += renderNodes(node.body, child, resolvePartial);
132
+ });
133
+ } else if (node.else.length) {
134
+ out += renderNodes(node.else, ctx, resolvePartial);
135
+ }
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // Render `src` against `ctx`. `resolvePartial(name, ctx)` returns the raw text for
142
+ // a `{{> name }}` include, or null to emit nothing.
143
+ function render(src, ctx = {}, resolvePartial = () => null) {
144
+ const clean = stripHeaderComment(String(src));
145
+ return renderNodes(parse(tokenize(clean)), ctx, resolvePartial);
146
+ }
147
+
148
+ module.exports = { render, stripHeaderComment };