@golden-frijoles/kit 0.0.0 → 0.3.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 +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/init.mjs +132 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +204 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/skeleton/Roadmap/00-ideas/README.md +85 -0
- package/dist/skeleton/Roadmap/00-ideas/audits/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/00-ideas/seeds/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/LEARNINGS.md +164 -0
- package/dist/skeleton/Roadmap/README.md +67 -0
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +233 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- package/package.json +33 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// doc-hygiene.mjs — measure the always-read session-start doc set and flag dedupe/staleness
|
|
3
|
+
// candidates in LEARNINGS.md + the README poster. ADVISORY ONLY: this script never edits any
|
|
4
|
+
// Roadmap doc — it only reads them and (unless --check) writes ONE new dated report file under
|
|
5
|
+
// Roadmap/00-ideas/. Every flagged candidate is a hint for a human/model to verify, not a verdict —
|
|
6
|
+
// see the `doc-hygiene` skill (`golden-frijoles` plugin, golden-frijoles marketplace) for the
|
|
7
|
+
// judgment pass this script's output feeds.
|
|
8
|
+
//
|
|
9
|
+
// node scripts/doc-hygiene.mjs # print the report + write Roadmap/00-ideas/DOC-HYGIENE-REPORT-<date>.md
|
|
10
|
+
// node scripts/doc-hygiene.mjs --check # print only, write nothing (quick look / CI-safe)
|
|
11
|
+
//
|
|
12
|
+
// Reuse, don't rebuild: epic status comes from `roadmap-extract.mjs` (the same SSOT
|
|
13
|
+
// build-order.mjs reads) — this script does not re-parse frontmatter itself.
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const REPO = projectRoot(); // D2
|
|
23
|
+
const ROADMAP = join(REPO, 'Roadmap');
|
|
24
|
+
const IDEAS = join(ROADMAP, '00-ideas');
|
|
25
|
+
|
|
26
|
+
// App roots are DISCOVERED, not hardcoded. The origin project listed its two apps by name, which
|
|
27
|
+
// silently degraded to "no app roots" in any project laid out differently — and this script's
|
|
28
|
+
// dead-path check reads a missing root as "path not found", i.e. it would have flagged every
|
|
29
|
+
// app-relative bullet as stale. Discovery keeps absent and unknown distinguishable.
|
|
30
|
+
function discoverAppRoots() {
|
|
31
|
+
const appsDir = join(REPO, 'apps');
|
|
32
|
+
if (!existsSync(appsDir)) return [];
|
|
33
|
+
return readdirSync(appsDir, { withFileTypes: true })
|
|
34
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
35
|
+
.map((e) => join(appsDir, e.name))
|
|
36
|
+
.sort();
|
|
37
|
+
}
|
|
38
|
+
const APP_ROOTS = discoverAppRoots();
|
|
39
|
+
|
|
40
|
+
// The always-read set — the "Start here" orientation list. AGENTS.md is taken from the repo root,
|
|
41
|
+
// falling back to a single app's copy when the repo keeps it there instead.
|
|
42
|
+
function resolveAgentsMd() {
|
|
43
|
+
const rootDoc = join(REPO, 'AGENTS.md');
|
|
44
|
+
if (existsSync(rootDoc)) return rootDoc;
|
|
45
|
+
const fromApp = APP_ROOTS.map((r) => join(r, 'AGENTS.md')).find((p) => existsSync(p));
|
|
46
|
+
return fromApp || rootDoc; // report the root path as missing rather than inventing one
|
|
47
|
+
}
|
|
48
|
+
const ALWAYS_READ = [
|
|
49
|
+
{ label: 'AGENTS.md', path: resolveAgentsMd() },
|
|
50
|
+
{ label: 'WAYS-OF-WORKING.md', path: join(ROADMAP, 'WAYS-OF-WORKING.md') },
|
|
51
|
+
{ label: 'LEARNINGS.md', path: join(ROADMAP, 'LEARNINGS.md') },
|
|
52
|
+
{ label: 'README.md (poster)', path: join(ROADMAP, 'README.md') },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// In-scope for candidate-flagging (per the doc-hygiene-learnings-sweep epic's v1 scope).
|
|
56
|
+
const FLAG_TARGETS = ['LEARNINGS.md', 'README.md (poster)'];
|
|
57
|
+
|
|
58
|
+
const STOPWORDS = new Set(
|
|
59
|
+
(
|
|
60
|
+
'the a an and or but of to in on for with at by from as is are was were be been being this that ' +
|
|
61
|
+
'it its own not no never every each any one two both same real still only just also once new'
|
|
62
|
+
).split(' ')
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
function measure() {
|
|
66
|
+
return ALWAYS_READ.map(({ label, path }) => {
|
|
67
|
+
if (!existsSync(path)) return { label, path, lines: 0, bytes: 0, missing: true };
|
|
68
|
+
const text = readFileSync(path, 'utf8');
|
|
69
|
+
return { label, path, lines: text.split('\n').length, bytes: Buffer.byteLength(text, 'utf8') };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Epic status via the existing extractor — never a second frontmatter parser.
|
|
74
|
+
function archivedEpicSlugs() {
|
|
75
|
+
try {
|
|
76
|
+
const json = execFileSync('node', [join(__dirname, 'roadmap-extract.mjs')], {
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
const rows = JSON.parse(json);
|
|
81
|
+
return rows
|
|
82
|
+
.filter((r) => r.grain === 'Epic' && r.status === 'Archived')
|
|
83
|
+
.map((r) => r.slug)
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
} catch {
|
|
86
|
+
return []; // extractor missing/broken shouldn't block the rest of the report
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Split a doc into { section, bullets: [{ text, line }] } by top-level "## " headers and "- " bullets.
|
|
91
|
+
function splitIntoBullets(text) {
|
|
92
|
+
const lines = text.split('\n');
|
|
93
|
+
let section = '(preamble)';
|
|
94
|
+
const bullets = [];
|
|
95
|
+
let current = null;
|
|
96
|
+
lines.forEach((line, i) => {
|
|
97
|
+
if (/^## /.test(line)) {
|
|
98
|
+
section = line.replace(/^## /, '').trim();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (/^- /.test(line)) {
|
|
102
|
+
if (current) bullets.push(current);
|
|
103
|
+
current = { section, line: i + 1, text: line };
|
|
104
|
+
} else if (current && /^\s+\S/.test(line)) {
|
|
105
|
+
current.text += ' ' + line.trim();
|
|
106
|
+
} else if (current && line.trim() === '') {
|
|
107
|
+
bullets.push(current);
|
|
108
|
+
current = null;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
if (current) bullets.push(current);
|
|
112
|
+
return bullets;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function significantWords(text) {
|
|
116
|
+
const words = text
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.replace(/[`*_()[\]{}"'.,:;!?]/g, ' ')
|
|
119
|
+
.split(/\s+/)
|
|
120
|
+
.filter((w) => w.length > 3 && !STOPWORDS.has(w));
|
|
121
|
+
return new Set(words);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function jaccard(a, b) {
|
|
125
|
+
let inter = 0;
|
|
126
|
+
for (const w of a) if (b.has(w)) inter++;
|
|
127
|
+
const union = a.size + b.size - inter;
|
|
128
|
+
return union === 0 ? 0 : inter / union;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Cheap near-duplicate detector: flag bullet pairs in the SAME section sharing a high fraction of
|
|
132
|
+
// significant words. Threshold is deliberately conservative — false negatives are cheap (a human
|
|
133
|
+
// reads the section anyway); false positives waste review time.
|
|
134
|
+
function findNearDuplicates(bullets, threshold = 0.5) {
|
|
135
|
+
const bySection = new Map();
|
|
136
|
+
for (const b of bullets) {
|
|
137
|
+
if (!bySection.has(b.section)) bySection.set(b.section, []);
|
|
138
|
+
bySection.get(b.section).push({ ...b, words: significantWords(b.text) });
|
|
139
|
+
}
|
|
140
|
+
const hits = [];
|
|
141
|
+
for (const [section, list] of bySection) {
|
|
142
|
+
for (let i = 0; i < list.length; i++) {
|
|
143
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
144
|
+
const sim = jaccard(list[i].words, list[j].words);
|
|
145
|
+
if (sim >= threshold) {
|
|
146
|
+
hits.push({ section, a: list[i].line, b: list[j].line, sim: Math.round(sim * 100) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return hits;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Bullets reference app-relative paths (`lib/x.ts`) without an `apps/<app>/` prefix, since each app
|
|
155
|
+
// is its own repo. Check candidate paths against every known app root, plus the repo root for
|
|
156
|
+
// scripts/infra paths — a hit in ANY root clears the flag.
|
|
157
|
+
const SEARCH_ROOTS = [REPO, ...APP_ROOTS];
|
|
158
|
+
|
|
159
|
+
// Flag a bullet whose text names a backtick-quoted file path that doesn't exist in THIS checkout,
|
|
160
|
+
// under any known app root. This is a hint only — apps/* are separate, gitignored repos here and can
|
|
161
|
+
// be stale locally, and a bullet may legitimately describe a file deliberately deleted as part of the
|
|
162
|
+
// change it documents. Never treat this as proof of staleness; verify against the app repo's own history.
|
|
163
|
+
function findDeadPathRefs(bullets) {
|
|
164
|
+
const pathRe = /`((?:apps|lib|app|scripts|infra)\/[^\s`]+\.[a-zA-Z0-9]+)`/g;
|
|
165
|
+
const hits = [];
|
|
166
|
+
for (const b of bullets) {
|
|
167
|
+
let m;
|
|
168
|
+
pathRe.lastIndex = 0;
|
|
169
|
+
while ((m = pathRe.exec(b.text))) {
|
|
170
|
+
const rel = m[1];
|
|
171
|
+
const existsAnywhere = SEARCH_ROOTS.some((root) => existsSync(join(root, rel)));
|
|
172
|
+
if (!existsAnywhere) hits.push({ section: b.section, line: b.line, path: rel });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return hits;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function findArchivedEpicMentions(bullets, archivedSlugs) {
|
|
179
|
+
if (archivedSlugs.length === 0) return [];
|
|
180
|
+
const hits = [];
|
|
181
|
+
for (const b of bullets) {
|
|
182
|
+
for (const slug of archivedSlugs) {
|
|
183
|
+
if (b.text.includes(slug)) hits.push({ section: b.section, line: b.line, slug });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return hits;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fmt(n) {
|
|
190
|
+
return n.toLocaleString('en-US');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildReport() {
|
|
194
|
+
const sizes = measure();
|
|
195
|
+
const totalBytes = sizes.reduce((a, s) => a + s.bytes, 0);
|
|
196
|
+
const totalLines = sizes.reduce((a, s) => a + s.lines, 0);
|
|
197
|
+
const archivedSlugs = archivedEpicSlugs();
|
|
198
|
+
|
|
199
|
+
const perFile = FLAG_TARGETS.map((label) => {
|
|
200
|
+
const entry = ALWAYS_READ.find((f) => f.label === label);
|
|
201
|
+
const text = existsSync(entry.path) ? readFileSync(entry.path, 'utf8') : '';
|
|
202
|
+
const bullets = splitIntoBullets(text);
|
|
203
|
+
return {
|
|
204
|
+
label,
|
|
205
|
+
dupes: findNearDuplicates(bullets),
|
|
206
|
+
deadPaths: findDeadPathRefs(bullets),
|
|
207
|
+
archivedMentions: findArchivedEpicMentions(bullets, archivedSlugs),
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
212
|
+
const lines = [];
|
|
213
|
+
lines.push(
|
|
214
|
+
`<!-- Advisory artifact from the doc-hygiene skill (scripts/doc-hygiene.mjs). Findings are proposals`,
|
|
215
|
+
` only — no LEARNINGS.md/README.md content was changed by this script. -->`,
|
|
216
|
+
'',
|
|
217
|
+
`# Doc hygiene report — ${date}`,
|
|
218
|
+
'',
|
|
219
|
+
'🧹 **doc-hygiene skill.** Advisory only — review by hand; nothing here gates or auto-edits.',
|
|
220
|
+
'',
|
|
221
|
+
'## Always-read set size',
|
|
222
|
+
''
|
|
223
|
+
);
|
|
224
|
+
lines.push('| Doc | Lines | KB |', '|---|---|---|');
|
|
225
|
+
for (const s of sizes) {
|
|
226
|
+
lines.push(
|
|
227
|
+
`| ${s.label} | ${s.missing ? '—' : fmt(s.lines)} | ${s.missing ? 'missing' : (s.bytes / 1024).toFixed(1)} |`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
lines.push(`| **Total** | **${fmt(totalLines)}** | **${(totalBytes / 1024).toFixed(1)}** |`, '');
|
|
231
|
+
|
|
232
|
+
for (const f of perFile) {
|
|
233
|
+
lines.push(`## ${f.label} — flagged candidates`, '');
|
|
234
|
+
if (f.dupes.length === 0 && f.deadPaths.length === 0 && f.archivedMentions.length === 0) {
|
|
235
|
+
lines.push('Nothing flagged this pass.', '');
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (f.dupes.length) {
|
|
239
|
+
lines.push(
|
|
240
|
+
`**Possible near-duplicate bullets** (same section, high word overlap — verify before merging):`,
|
|
241
|
+
''
|
|
242
|
+
);
|
|
243
|
+
for (const d of f.dupes)
|
|
244
|
+
lines.push(
|
|
245
|
+
`- \`${f.label}\` section "${d.section}": line ${d.a} vs line ${d.b} (${d.sim}% shared words)`
|
|
246
|
+
);
|
|
247
|
+
lines.push('');
|
|
248
|
+
}
|
|
249
|
+
if (f.deadPaths.length) {
|
|
250
|
+
lines.push(
|
|
251
|
+
`**Referenced paths not found in this checkout** (verify against the app repo before treating as stale — this checkout can be behind the app's own \`main\`):`,
|
|
252
|
+
''
|
|
253
|
+
);
|
|
254
|
+
for (const d of f.deadPaths) lines.push(`- \`${f.label}\` line ${d.line}: \`${d.path}\``);
|
|
255
|
+
lines.push('');
|
|
256
|
+
}
|
|
257
|
+
if (f.archivedMentions.length) {
|
|
258
|
+
lines.push(`**Mentions an archived epic** (check whether the lesson is superseded):`, '');
|
|
259
|
+
for (const d of f.archivedMentions)
|
|
260
|
+
lines.push(`- \`${f.label}\` line ${d.line}: mentions archived epic \`${d.slug}\``);
|
|
261
|
+
lines.push('');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
lines.push('---', 'Advisory only — never auto-edits. Review, then hand-merge any accepted change.', '');
|
|
266
|
+
return { markdown: lines.join('\n'), sizes, totalBytes, totalLines };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const { markdown, totalBytes, totalLines } = buildReport();
|
|
270
|
+
console.log(markdown);
|
|
271
|
+
|
|
272
|
+
if (!process.argv.includes('--check')) {
|
|
273
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
274
|
+
const outPath = join(IDEAS, `DOC-HYGIENE-REPORT-${date}.md`);
|
|
275
|
+
writeFileSync(outPath, markdown);
|
|
276
|
+
console.error(
|
|
277
|
+
`\nWrote ${outPath} (always-read set: ${fmt(totalLines)} lines / ${(totalBytes / 1024).toFixed(1)} KB)`
|
|
278
|
+
);
|
|
279
|
+
}
|
package/dist/init.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// init.mjs — `gf-kit init`: adopt any repo by writing the Roadmap/ skeleton (golden-frijoles-plugin S3.2, D2).
|
|
3
|
+
//
|
|
4
|
+
// node scripts/init.mjs # write the skeleton into projectRoot(); never overwrite a file
|
|
5
|
+
//
|
|
6
|
+
// ── Why ───────────────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
// A stranger who pastes the install prompt into an EXISTING repo gets the plugin, but `groom` needs
|
|
8
|
+
// somewhere to write on day one — `Roadmap/README.md`, `WAYS-OF-WORKING.md`, `LEARNINGS.md`, and the
|
|
9
|
+
// `00-ideas/` funnel. `gf-kit init` is that one step: it writes the same skeleton a project spawned
|
|
10
|
+
// from `template/` already has, into any repo, copied or installed (D2's two roots).
|
|
11
|
+
//
|
|
12
|
+
// ── Where the skeleton's SOURCE lives ─────────────────────────────────────────────────────────
|
|
13
|
+
// INSTALLED: `kitRoot()/skeleton/` — `scripts/build-kit.mjs` copies `template/<each SKELETON path>`
|
|
14
|
+
// into `kit/dist/skeleton/`, reading the very list exported below, so there is one list, not two.
|
|
15
|
+
// COPIED: `kitRoot()/../` — when this file runs as `template/scripts/init.mjs` (this repo's own dev
|
|
16
|
+
// checkout) that is `template/`, the skeleton's real source; when it runs as a spawned project's own
|
|
17
|
+
// `scripts/init.mjs`, that is the project's own root, which already has every file, so every write is
|
|
18
|
+
// a no-op `skipped … (exists)` — never a broken read.
|
|
19
|
+
//
|
|
20
|
+
// ── The one rule ──────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// It NEVER overwrites a file that already exists (it prints `skipped <path> (exists)`), so it is safe
|
|
22
|
+
// to run on a partially-adopted repo. It writes nothing outside `Roadmap/`. Its own exit code is 0
|
|
23
|
+
// whether it wrote anything or not — "already adopted" is success, not a no-op failure.
|
|
24
|
+
//
|
|
25
|
+
// ── Two guards found by cross-review on #47 ───────────────────────────────────────────────────
|
|
26
|
+
// (1) COPIED mode, source === destination: `skeletonRoot()` in copied mode is `kitRoot()/..`, the
|
|
27
|
+
// SAME directory `projectRoot()` resolves to (this repo's own dev checkout runs exactly this way).
|
|
28
|
+
// So `join(source, rel)` and `join(project, rel)` are the identical path — reproduced by deleting
|
|
29
|
+
// one SKELETON file from a template/ checkout and running `node scripts/init.mjs`: the destination
|
|
30
|
+
// "doesn't exist" (it was deleted), so the copy proceeds, and the SOURCE (the same path) doesn't
|
|
31
|
+
// exist either — `copyFileSync` throws ENOENT instead of a clean skip. A stranger in installed mode
|
|
32
|
+
// never hits this (source and project are genuinely different directories there), but the repo's
|
|
33
|
+
// own dev loop and any future copied-mode caller can. Treated as `skipped`, same as "exists" — the
|
|
34
|
+
// file not being copyable from itself is not a failure this tool should crash on.
|
|
35
|
+
// (2) INSTALLED mode, project root resolves to `$HOME`: `projectRoot()` walks up to the nearest
|
|
36
|
+
// `Roadmap/` or `.git`. A non-git folder under a dotfiles repo tracked at `$HOME` (a common setup)
|
|
37
|
+
// has no closer match, so the walk lands on `$HOME` itself — and writing `Roadmap/` there would
|
|
38
|
+
// scatter the skeleton across the operator's actual home directory. Refused outright.
|
|
39
|
+
//
|
|
40
|
+
// Zero deps — Node 18+.
|
|
41
|
+
|
|
42
|
+
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
import { dirname, join, resolve } from 'node:path';
|
|
45
|
+
import { isInstalled, kitRoot, projectRoot } from './lib/project-root.mjs';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Roadmap skeleton `gf-kit init` writes, relative both to its source root (template/, or
|
|
49
|
+
* kit/dist/skeleton/ once built) and to the project it writes into. ONE list: `build-kit.mjs` reads
|
|
50
|
+
* this export to populate `kit/dist/skeleton/`, so there is no second copy of the file names to drift.
|
|
51
|
+
*/
|
|
52
|
+
export const SKELETON = [
|
|
53
|
+
'Roadmap/README.md',
|
|
54
|
+
'Roadmap/WAYS-OF-WORKING.md',
|
|
55
|
+
'Roadmap/LEARNINGS.md',
|
|
56
|
+
'Roadmap/00-ideas/README.md',
|
|
57
|
+
'Roadmap/00-ideas/seeds/.gitkeep',
|
|
58
|
+
'Roadmap/00-ideas/audits/.gitkeep',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** Where the skeleton's SOURCE files live: `kitRoot()/skeleton` when installed, `kitRoot()/..` when copied. */
|
|
62
|
+
export function skeletonRoot({ root = kitRoot(), installed = isInstalled({ root }) } = {}) {
|
|
63
|
+
return installed ? join(root, 'skeleton') : join(root, '..');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write the skeleton into `project`, never overwriting. Pure given its injected fs functions.
|
|
68
|
+
* Returns `{ wrote, skipped }`, each a list of the SKELETON-relative paths.
|
|
69
|
+
*/
|
|
70
|
+
export function initSkeleton({
|
|
71
|
+
project = projectRoot(),
|
|
72
|
+
source = skeletonRoot(),
|
|
73
|
+
files = SKELETON,
|
|
74
|
+
exists = existsSync,
|
|
75
|
+
mkdir = mkdirSync,
|
|
76
|
+
copy = copyFileSync,
|
|
77
|
+
log = () => {},
|
|
78
|
+
} = {}) {
|
|
79
|
+
const wrote = [];
|
|
80
|
+
const skipped = [];
|
|
81
|
+
for (const rel of files) {
|
|
82
|
+
const dest = join(project, rel);
|
|
83
|
+
if (exists(dest)) {
|
|
84
|
+
skipped.push(rel);
|
|
85
|
+
log(`skipped ${rel} (exists)`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const from = join(source, rel);
|
|
89
|
+
// Copied mode's source and destination roots can be the identical directory (see the header) —
|
|
90
|
+
// a source that is the destination, or that simply isn't there, is a skip, never a crash. The
|
|
91
|
+
// destination not existing (checked above) does NOT imply the source exists: they can be the
|
|
92
|
+
// same absent path.
|
|
93
|
+
if (from === dest || !exists(from)) {
|
|
94
|
+
skipped.push(rel);
|
|
95
|
+
log(`skipped ${rel} (${from === dest ? 'source is the destination' : 'source is absent'})`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
mkdir(dirname(dest), { recursive: true });
|
|
99
|
+
copy(from, dest);
|
|
100
|
+
wrote.push(rel);
|
|
101
|
+
log(`wrote ${rel}`);
|
|
102
|
+
}
|
|
103
|
+
return { wrote, skipped };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* True when `project` resolves to the operator's own home directory — the one place `gf-kit init`
|
|
108
|
+
* must never write, even though `projectRoot()`'s walk-up can legitimately land there (a non-git
|
|
109
|
+
* folder under a dotfiles repo tracked at `$HOME`). Pure given its injected `home`.
|
|
110
|
+
*/
|
|
111
|
+
export function isHomeDirectory({ project = projectRoot(), home = homedir() } = {}) {
|
|
112
|
+
return resolve(project) === resolve(home);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function main() {
|
|
116
|
+
const project = projectRoot();
|
|
117
|
+
if (isHomeDirectory({ project })) {
|
|
118
|
+
console.error(
|
|
119
|
+
`gf-kit init: refusing to run — the resolved project root is your home directory (${project}).\n` +
|
|
120
|
+
' This usually means no Roadmap/ or .git was found between the current directory and $HOME,\n' +
|
|
121
|
+
' and $HOME itself is a git repo (a dotfiles checkout, for example). Run this from inside a\n' +
|
|
122
|
+
' real project — a directory with its own .git — or pass --root explicitly.'
|
|
123
|
+
);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
const { wrote, skipped } = initSkeleton({ project, log: (line) => console.log(line) });
|
|
127
|
+
console.log(`gf-kit init: ${wrote.length} written, ${skipped.length} already present.`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const isMain = process.argv[1] && process.argv[1].endsWith('init.mjs');
|
|
132
|
+
if (isMain) process.exitCode = main();
|