@golden-frijoles/kit 0.41.0 → 0.42.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.
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ // strategy-private.mjs — keep the maker's strategy out of a public repo unless they chose to commit it (coaches-v2 D9).
3
+ //
4
+ // node scripts/strategy-private.mjs ensure decide, act at most once, print one line for the maker
5
+ // node scripts/strategy-private.mjs check the same decision, never writes
6
+ //
7
+ // ── Why ────────────────────────────────────────────────────────────────────────────────────────────────────────────
8
+ // The coaches write positioning, pricing and the riskiest assumption to `Roadmap/00-strategy/`. On a public repo the
9
+ // next `git add .` publishes them, and nothing asked (dogfood F2). So before a coach's first write this adds the
10
+ // folder to `.gitignore` when the repo is public, or when its visibility can't be read: "couldn't tell" must fail
11
+ // towards private, because the cost of a wrong guess is one-way.
12
+ //
13
+ // It never takes away a choice the maker already made:
14
+ // • a file under the folder is already committed → they commit strategy here; leave it
15
+ // • `.gitignore` says `!Roadmap/00-strategy/` → the explicit opt-in; leave it (a re-run must not undo it)
16
+ // • the folder is already ignored → nothing to do
17
+ //
18
+ // Exit codes: 0 decided (whether or not it wrote) · 2 usage.
19
+ // Zero deps — Node 18+.
20
+ import { spawnSync } from 'node:child_process';
21
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { projectRoot } from './lib/project-root.mjs';
25
+ import { STRATEGY_DIR } from './lib/strategy-files.mjs';
26
+
27
+ export const IGNORE_LINE = `${STRATEGY_DIR.split('\\').join('/')}/`;
28
+ export const OPT_IN_LINE = `!${IGNORE_LINE}`;
29
+
30
+ export const COMMENT_LINE = `# Strategy files are the maker's own. To commit them, change the next line to ${OPT_IN_LINE}`;
31
+
32
+ /**
33
+ * True when `.gitignore` text carries an opt-in in any spelling git honours (`!Roadmap/00-strategy`, `!/…/`, `!…/**`),
34
+ * or when our own comment is there but the line under it was deleted: both are a maker saying "commit it", and a later
35
+ * coach must never undo either (review of #315). A negation in a NESTED .gitignore is caught by git itself in
36
+ * readFacts (`negatedBy`), not here.
37
+ */
38
+ export function hasOptIn(gitignore) {
39
+ const lines = String(gitignore ?? '')
40
+ .split(/\r?\n/)
41
+ .map((l) => l.trim());
42
+ const folder = IGNORE_LINE.replace(/\/$/, '');
43
+ const norm = (l) => l.replace(/^!\/?/, '').replace(/\/(\*\*?)?$/, '');
44
+ if (lines.some((l) => l.startsWith('!') && norm(l) === folder)) return true;
45
+ const at = lines.indexOf(COMMENT_LINE);
46
+ return at !== -1 && !lines.slice(at + 1).some((l) => !l.startsWith('!') && norm(l) === folder);
47
+ }
48
+
49
+ /**
50
+ * Pure — what to do. `visibility` is 'public' | 'private' | 'internal' | 'unknown'; `isRepo` false when there is no
51
+ * git repository at all. Returns { write: boolean, line: string }.
52
+ */
53
+ export function decide({ isRepo, visibility, tracked, ignored, optedIn }) {
54
+ if (!isRepo)
55
+ return {
56
+ write: false,
57
+ line: 'Strategy files: not a git repository, so there is nothing to keep out of git.',
58
+ };
59
+ if (optedIn)
60
+ return {
61
+ write: false,
62
+ line: `Strategy files: you chose to commit them (\`${OPT_IN_LINE}\` in .gitignore).`,
63
+ };
64
+ if (tracked > 0)
65
+ return {
66
+ write: false,
67
+ line: `Strategy files: ${tracked} already committed here, so they stay committed. To stop, run \`git rm -r --cached ${IGNORE_LINE}\` and add \`${IGNORE_LINE}\` to .gitignore.`,
68
+ };
69
+ if (ignored) return { write: false, line: 'Strategy files: already kept out of git.' };
70
+ if (visibility === 'private' || visibility === 'internal')
71
+ return {
72
+ write: false,
73
+ line: `Strategy files: this repo is ${visibility}, so they can be committed with it.`,
74
+ };
75
+ const why = visibility === 'public' ? 'this repo is public' : "this repo's visibility couldn't be read";
76
+ return {
77
+ write: true,
78
+ line: `Strategy files: kept out of git because ${why} (added \`${IGNORE_LINE}\` to .gitignore). To commit them instead, delete that line or change it to \`${OPT_IN_LINE}\`.`,
79
+ };
80
+ }
81
+
82
+ /** Pure — `.gitignore` with the folder appended, keeping whatever was there. */
83
+ export function appendIgnore(text) {
84
+ const base = String(text ?? '');
85
+ const sep = base === '' || base.endsWith('\n') ? '' : '\n';
86
+ return `${base}${sep}${COMMENT_LINE}\n${IGNORE_LINE}\n`;
87
+ }
88
+
89
+ /**
90
+ * Run git against `root` only. A hook exports GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE, and git obeys them over `-C`:
91
+ * left in, a coach run from a hook would check the OUTER repo and could leave a public one unprotected (review of
92
+ * #315; LEARNINGS 2026-10-06 on hooks retargeting git).
93
+ */
94
+ function git(root, args, env) {
95
+ const clean = { ...env };
96
+ delete clean.GIT_DIR;
97
+ delete clean.GIT_WORK_TREE;
98
+ delete clean.GIT_INDEX_FILE;
99
+ return spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', env: clean });
100
+ }
101
+
102
+ /** The facts `decide` needs, read from the repo. `gh` failing for any reason reads as 'unknown'. */
103
+ export function readFacts(root, { run = spawnSync, env = process.env } = {}) {
104
+ const isRepo = git(root, ['rev-parse', '--is-inside-work-tree'], env).stdout.trim() === 'true';
105
+ if (!isRepo) return { isRepo };
106
+ const gitignorePath = join(root, '.gitignore');
107
+ const gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
108
+ const tracked = git(root, ['ls-files', '--', IGNORE_LINE], env).stdout.split('\n').filter(Boolean).length;
109
+ const probe = `${IGNORE_LINE}probe.md`;
110
+ const ignored = git(root, ['check-ignore', '-q', '--no-index', probe], env).status === 0;
111
+ // `-v -n` names the rule that decides the probe even when it is a negation: a `!` rule anywhere, in any
112
+ // .gitignore, is the maker un-ignoring the folder on purpose.
113
+ const negatedBy = git(root, ['check-ignore', '-v', '-n', '--no-index', probe], env).stdout.split('\t')[0];
114
+ // A nested `!` that re-includes the folder can leave git naming no rule at all; the root .gitignore listing the
115
+ // folder while git says it is NOT ignored means the same thing: something un-ignores it on purpose.
116
+ const folder = IGNORE_LINE.replace(/\/$/, '');
117
+ const listed = gitignore.split(/\r?\n/).some(
118
+ (l) =>
119
+ l
120
+ .trim()
121
+ .replace(/^\//, '')
122
+ .replace(/\/(\*\*?)?$/, '') === folder
123
+ );
124
+ const negated = /^[^:]+:\d+:!/.test(negatedBy) || (listed && !ignored);
125
+ const gh = run('gh', ['repo', 'view', '--json', 'visibility', '-q', '.visibility'], {
126
+ cwd: root,
127
+ encoding: 'utf8',
128
+ });
129
+ const raw = gh.status === 0 ? String(gh.stdout).trim().toLowerCase() : '';
130
+ const visibility = ['public', 'private', 'internal'].includes(raw) ? raw : 'unknown';
131
+ return {
132
+ isRepo,
133
+ visibility,
134
+ tracked,
135
+ ignored,
136
+ optedIn: negated || hasOptIn(gitignore),
137
+ gitignorePath,
138
+ gitignore,
139
+ };
140
+ }
141
+
142
+ function main(argv) {
143
+ const cmd = argv[0];
144
+ if (cmd !== 'ensure' && cmd !== 'check') {
145
+ process.stderr.write('usage: strategy-private.mjs ensure | check\n');
146
+ return 2;
147
+ }
148
+ const facts = readFacts(projectRoot());
149
+ const d = decide(facts);
150
+ if (d.write && cmd === 'ensure') writeFileSync(facts.gitignorePath, appendIgnore(facts.gitignore));
151
+ process.stdout.write(
152
+ `${cmd === 'check' && d.write ? d.line.replace('kept out of git', 'would be kept out of git').replace('added', 'would add') : d.line}\n`
153
+ );
154
+ return 0;
155
+ }
156
+
157
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])
158
+ process.exitCode = main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {