@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.
- package/README.md +60 -23
- package/bin/cairn.js +13 -5
- package/lib/doctor/index.js +6 -1
- package/lib/init.js +15 -1
- package/lib/manifest.js +33 -0
- package/lib/render/cli.js +116 -0
- package/lib/render/engine.js +148 -0
- package/lib/render/index.js +220 -0
- package/lib/sync/cli.js +197 -0
- package/lib/sync/index.js +249 -0
- package/package.json +2 -1
- package/presets/EXTRACTION.md +210 -0
- package/presets/README.md +86 -0
- package/presets/angular/code-guidelines.md +197 -0
- package/presets/angular/slots/architect-discussion-topics.md +10 -0
- package/presets/angular/slots/architect-mandatory-docs.md +5 -0
- package/presets/angular/slots/architect-references.md +4 -0
- package/presets/angular/slots/implementation-reference.md +34 -0
- package/presets/angular/slots/key-patterns.md +11 -0
- package/presets/angular/slots/plan-step-ordering.md +11 -0
- package/presets/angular/slots/review-checklist.md +16 -0
- package/presets/angular/variants/i18n-external-service.md +11 -0
- package/presets/angular/variants/i18n-glossr.md +63 -0
- package/presets/core/AGENTS.md +49 -0
- package/presets/core/README.md +35 -0
- package/presets/core/WORKFLOW.md +56 -0
- package/presets/core/agents/architect.md +269 -0
- package/presets/core/agents/developer.md +145 -0
- package/presets/core/agents/reviewer.md +167 -0
- package/presets/core/commands/_stub.md +7 -0
- package/presets/core/workflow.json +45 -0
- package/presets/drizzle/code-guidelines.md +33 -0
- package/presets/drizzle/slots/architect-discussion-topics.md +4 -0
- package/presets/drizzle/slots/architect-mandatory-docs.md +4 -0
- package/presets/drizzle/slots/implementation-reference.md +17 -0
- package/presets/drizzle/slots/key-patterns.md +7 -0
- package/presets/drizzle/slots/review-checklist.md +10 -0
- package/presets/nestjs/code-guidelines.md +273 -0
- package/presets/nestjs/slots/architect-discussion-topics.md +4 -0
- package/presets/nestjs/slots/architect-mandatory-docs.md +5 -0
- package/presets/nestjs/slots/architect-references.md +5 -0
- package/presets/nestjs/slots/implementation-reference.md +45 -0
- package/presets/nestjs/slots/key-patterns.md +11 -0
- package/presets/nestjs/slots/plan-step-ordering.md +12 -0
- package/presets/nestjs/slots/review-checklist.md +12 -0
- package/presets/nestjs/variants/validation-class-validator.md +120 -0
- package/presets/nestjs/variants/validation-zod.md +194 -0
- package/presets/nextjs/code-guidelines.md +45 -0
- package/presets/nextjs/slots/architect-discussion-topics.md +5 -0
- package/presets/nextjs/slots/architect-mandatory-docs.md +3 -0
- package/presets/nextjs/slots/architect-references.md +6 -0
- package/presets/nextjs/slots/implementation-reference.md +24 -0
- package/presets/nextjs/slots/key-patterns.md +8 -0
- package/presets/nextjs/slots/plan-step-ordering.md +11 -0
- package/presets/nextjs/slots/review-checklist.md +11 -0
- package/presets/react/code-guidelines.md +46 -0
- package/presets/react/slots/architect-discussion-topics.md +5 -0
- package/presets/react/slots/architect-references.md +5 -0
- package/presets/react/slots/implementation-reference.md +26 -0
- package/presets/react/slots/key-patterns.md +8 -0
- package/presets/react/slots/plan-step-ordering.md +9 -0
- package/presets/react/slots/review-checklist.md +10 -0
- package/presets/tailwind/code-guidelines.md +28 -0
- package/presets/tailwind/slots/implementation-reference.md +8 -0
- package/presets/tailwind/slots/key-patterns.md +5 -0
- package/presets/tailwind/slots/review-checklist.md +8 -0
- package/presets/typeorm/code-guidelines.md +329 -0
- package/presets/typeorm/slots/architect-discussion-topics.md +4 -0
- package/presets/typeorm/slots/architect-mandatory-docs.md +3 -0
- package/presets/typeorm/slots/implementation-reference.md +19 -0
- package/presets/typeorm/slots/key-patterns.md +8 -0
- 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
|
|
7
|
-
mirrored into an issue tracker.
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
36
|
-
_cairn/
|
|
37
|
-
manifest.json
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
128
|
-
| `DELETED` | it is gone;
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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':
|
package/lib/doctor/index.js
CHANGED
|
@@ -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) =>
|
|
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)
|
|
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 };
|