create-afe-project 0.0.0-stage → 1.0.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 (59) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/LICENSE +21 -0
  3. package/README.md +77 -2
  4. package/bin/create-afe-project.js +123 -0
  5. package/package.json +43 -4
  6. package/src/answers.js +147 -0
  7. package/src/config.js +152 -0
  8. package/src/project.js +217 -0
  9. package/src/render.js +51 -0
  10. package/template/.claude/harness.conf +47 -0
  11. package/template/.claude/hooks/post-edit-check.sh +38 -0
  12. package/template/.claude/hooks/protect-docs.sh +315 -0
  13. package/template/.claude/hooks/stop-check-decisions.sh +25 -0
  14. package/template/.claude/hooks/stop-check-docs.sh +29 -0
  15. package/template/.claude/hooks/stop-check-template.sh +23 -0
  16. package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
  17. package/template/.claude/path-classes +61 -0
  18. package/template/.claude/settings.project.json +5 -0
  19. package/template/.claude/settings.template.json +76 -0
  20. package/template/.claude/skills/afe-init/SKILL.md +104 -0
  21. package/template/.claude/skills/create-pr/SKILL.md.jinja +107 -0
  22. package/template/.claude/skills/update-template/SKILL.md +40 -0
  23. package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +46 -0
  24. package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +56 -0
  25. package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
  26. package/template/.github/workflows/decisions.yml.jinja +47 -0
  27. package/template/.github/workflows/docs.yml.jinja +40 -0
  28. package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +51 -0
  29. package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +49 -0
  30. package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +40 -0
  31. package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
  32. package/template/.markdownlint-cli2.jsonc +27 -0
  33. package/template/.template/owned.txt.jinja +63 -0
  34. package/template/AGENTS.md.jinja +91 -0
  35. package/template/CLAUDE.md +3 -0
  36. package/template/README.md.jinja +41 -0
  37. package/template/_gitignore +14 -0
  38. package/template/docs/README.md.jinja +44 -0
  39. package/template/docs/contributing.md.jinja +206 -0
  40. package/template/docs/decisions/adr-template.md +38 -0
  41. package/template/docs/documentation-rules.md.jinja +200 -0
  42. package/template/docs/testing-rules.md +80 -0
  43. package/template/scripts/check-decisions.sh +382 -0
  44. package/template/scripts/check-docs.sh +20 -0
  45. package/template/scripts/check-links.mjs +142 -0
  46. package/template/scripts/check-project.sh +27 -0
  47. package/template/scripts/check-template.sh +88 -0
  48. package/template/scripts/check.sh +21 -0
  49. package/template/scripts/env.sh +29 -0
  50. package/template/scripts/gen-decisions-index.sh +57 -0
  51. package/template/scripts/gen-settings.sh +14 -0
  52. package/template/scripts/lib/classes.sh +89 -0
  53. package/template/scripts/lib/git-base.sh +23 -0
  54. package/template/scripts/lib/harness-conf.sh +48 -0
  55. package/template/scripts/lib/json.mjs +160 -0
  56. package/template/scripts/pr-metrics.sh +80 -0
  57. package/template/scripts/protect-branch.sh +53 -0
  58. package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
  59. package/template/scripts/{% if fail_without_change %}fail-without-change.sh{% endif %} +88 -0
package/src/answers.js ADDED
@@ -0,0 +1,147 @@
1
+ // Collects the answers to the template's questions, and reads and writes the answers file that
2
+ // records them with the template version.
3
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import * as p from '@clack/prompts';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import { ANSWERS_FILE, LEGACY_ANSWERS_FILE, QUESTIONS, computed } from './config.js';
8
+
9
+ export class UsageError extends Error {}
10
+
11
+ /**
12
+ * Turns a `--data` string into the type of its question.
13
+ * @param {string} name
14
+ * @param {unknown} value
15
+ * @returns {unknown}
16
+ */
17
+ export function coerce(name, value) {
18
+ const q = QUESTIONS.find((x) => x.name === name);
19
+ if (!q) throw new UsageError(`unknown question: ${name}`);
20
+ if (q.type === 'bool' && typeof value === 'string') {
21
+ if (/^(true|yes|y|1)$/i.test(value)) return true;
22
+ if (/^(false|no|n|0)$/i.test(value)) return false;
23
+ throw new UsageError(`${name}: expected true or false, got ${value}`);
24
+ }
25
+ if (q.type === 'choice' && !q.choices.some((c) => c.value === value)) {
26
+ throw new UsageError(`${name}: expected one of ${q.choices.map((c) => c.value).join(', ')}`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function check(q, value) {
32
+ const error = q.validate?.(String(value));
33
+ if (error) throw new UsageError(`${q.name}: ${error}`);
34
+ }
35
+
36
+ async function ask(q, initial) {
37
+ let value;
38
+ if (q.type === 'bool') {
39
+ value = await p.confirm({ message: q.help, initialValue: initial ?? q.default });
40
+ } else if (q.type === 'choice') {
41
+ value = await p.select({
42
+ message: q.help,
43
+ options: q.choices.map((c) => ({ value: c.value, label: c.label })),
44
+ initialValue: initial ?? q.default,
45
+ });
46
+ } else {
47
+ const start = initial ?? q.default;
48
+ value = await p.text({
49
+ message: q.help,
50
+ initialValue: start,
51
+ placeholder: start,
52
+ validate: (v) => q.validate?.(v ?? ''),
53
+ });
54
+ }
55
+ if (p.isCancel(value)) {
56
+ p.cancel('Cancelled; nothing was written.');
57
+ process.exit(130);
58
+ }
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Answers every question in order. A value from `given` is taken without asking; then, with
64
+ * `useDefaults`, the previous answer or the question's default; a question with neither is asked,
65
+ * or fails when `interactive` is false. A question whose `when` is false is not answered.
66
+ * @param {{ given?: Record<string, unknown>, previous?: Record<string, unknown>,
67
+ * useDefaults?: boolean, interactive?: boolean }} options
68
+ * @returns {Promise<{ answers: Record<string, unknown>, context: Record<string, unknown> }>}
69
+ * the answers to record, and the rendering context with every question and computed value
70
+ */
71
+ export async function collectAnswers({ given = {}, previous = {}, useDefaults = false, interactive = true }) {
72
+ for (const name of Object.keys(given)) coerce(name, given[name]);
73
+ const answers = {};
74
+ const context = {};
75
+ for (const q of QUESTIONS) {
76
+ if (q.when && !q.when(context)) {
77
+ context[q.name] = q.default ?? '';
78
+ continue;
79
+ }
80
+ let value;
81
+ if (q.name in given) {
82
+ value = coerce(q.name, given[q.name]);
83
+ } else if (useDefaults && (q.name in previous || 'default' in q)) {
84
+ value = q.name in previous ? previous[q.name] : q.default;
85
+ } else if (interactive) {
86
+ value = await ask(q, previous[q.name]);
87
+ } else {
88
+ throw new UsageError(`${q.name} has no answer; pass --data ${q.name}=<value> or run in a terminal`);
89
+ }
90
+ check(q, value);
91
+ answers[q.name] = value;
92
+ context[q.name] = value;
93
+ }
94
+ return { answers, context: { ...context, ...computed(context) } };
95
+ }
96
+
97
+ /**
98
+ * Reads the recorded answers of a project: the answers file, or a Copier answers file of a project
99
+ * generated before the template moved to npm.
100
+ * @param {string} dir
101
+ * @returns {{ version: string | null, answers: Record<string, unknown>, legacy: boolean } | null}
102
+ */
103
+ export function readAnswers(dir) {
104
+ const file = join(dir, ANSWERS_FILE);
105
+ if (existsSync(file)) {
106
+ const { _version = null, ...rest } = JSON.parse(readFileSync(file, 'utf8'));
107
+ return { version: _version, answers: strip(rest), legacy: false };
108
+ }
109
+ const legacy = join(dir, LEGACY_ANSWERS_FILE);
110
+ if (existsSync(legacy)) {
111
+ const data = parseYaml(readFileSync(legacy, 'utf8')) ?? {};
112
+ const commit = typeof data._commit === 'string' ? data._commit.replace(/^v/, '').split('-')[0] : null;
113
+ return { version: commit, answers: strip(data), legacy: true };
114
+ }
115
+ return null;
116
+ }
117
+
118
+ function strip(data) {
119
+ return Object.fromEntries(Object.entries(data).filter(([k]) => !k.startsWith('_')));
120
+ }
121
+
122
+ /**
123
+ * @param {string} dir
124
+ * @param {string} version
125
+ * @param {Record<string, unknown>} answers
126
+ */
127
+ export function writeAnswers(dir, version, answers) {
128
+ const data = {
129
+ _note: 'Written by create-afe-project; never edit by hand (docs/documentation-rules.md, section 9).',
130
+ _version: version,
131
+ ...answers,
132
+ };
133
+ writeFileSync(join(dir, ANSWERS_FILE), `${JSON.stringify(data, null, 2)}\n`);
134
+ }
135
+
136
+ /**
137
+ * Compares two versions MAJOR.MINOR.PATCH; a missing version is the lowest.
138
+ * @param {string | null} a
139
+ * @param {string | null} b
140
+ * @returns {number}
141
+ */
142
+ export function compareVersions(a, b) {
143
+ const parts = (v) => (v ?? '0.0.0').split('.').map((n) => Number.parseInt(n, 10) || 0);
144
+ const [x, y] = [parts(a), parts(b)];
145
+ for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
146
+ return 0;
147
+ }
package/src/config.js ADDED
@@ -0,0 +1,152 @@
1
+ // The template's questions, file classes, tasks, and migrations: what copier.yml held before the
2
+ // template moved to npm. Maintainers: docs/maintaining.md.
3
+
4
+ export const ANSWERS_FILE = '.afe-answers.json';
5
+ export const LEGACY_ANSWERS_FILE = '.copier-answers.yml';
6
+ export const TEMPLATE_URL = 'https://github.com/dev-oleksandrv/create-afe-project';
7
+
8
+ // Template files whose name differs from the generated one, because npm never publishes the
9
+ // generated name.
10
+ export const RENAMES = { _gitignore: '.gitignore' };
11
+
12
+ // Seeded files: created once, owned by the project afterwards (docs/documentation-rules.md,
13
+ // section 9, in a generated project). An update creates a missing one and never changes one.
14
+ export const SEEDED = [
15
+ 'README.md',
16
+ 'CLAUDE.md',
17
+ '.gitignore',
18
+ 'docs/README.md',
19
+ '.claude/path-classes',
20
+ '.claude/harness.conf',
21
+ '.claude/settings.project.json',
22
+ ];
23
+
24
+ // Generated files: the Claude Code settings, the ownership manifest, and the decision index are
25
+ // rebuilt after every create and update, in this order.
26
+ export const TASKS = [
27
+ 'scripts/gen-settings.sh',
28
+ 'scripts/check-template.sh --write',
29
+ 'scripts/gen-decisions-index.sh > docs/decisions/README.md',
30
+ ];
31
+
32
+ const LABELS = /^\[\s*"[A-Za-z0-9_.:-]+"(\s*,\s*"[A-Za-z0-9_.:-]+")*\s*\]$/;
33
+
34
+ // The wizard, in order. `when` decides whether a question is asked; an unasked question takes no
35
+ // answer. `validate` returns an error message or nothing.
36
+ export const QUESTIONS = [
37
+ {
38
+ name: 'project_name',
39
+ type: 'str',
40
+ help: 'Project name (for example: acme)',
41
+ validate: (v) => (v.trim() ? undefined : 'Required'),
42
+ },
43
+ {
44
+ name: 'project_description',
45
+ type: 'str',
46
+ help: 'What is the project? One sentence, ending with a period',
47
+ validate: (v) => (v.trim().endsWith('.') ? undefined : 'End the sentence with a period'),
48
+ },
49
+ {
50
+ name: 'github_repo',
51
+ type: 'str',
52
+ help: 'GitHub repository (owner/name)',
53
+ validate: (v) => (/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(v) ? undefined : 'Use the form owner/name'),
54
+ },
55
+ {
56
+ name: 'ci_runner',
57
+ type: 'choice',
58
+ help: 'Where should CI run? (the repository variable CI_RUNS_ON can change it later)',
59
+ choices: [
60
+ { label: "On GitHub's servers (ubuntu-latest)", value: 'github-hosted' },
61
+ { label: 'On your own Linux machine (self-hosted runner)', value: 'self-hosted' },
62
+ ],
63
+ default: 'github-hosted',
64
+ },
65
+ {
66
+ name: 'self_hosted_labels',
67
+ type: 'str',
68
+ help: 'Labels of your runner, as a JSON array (for example: ["self-hosted", "linux"])',
69
+ default: '["self-hosted"]',
70
+ when: (a) => a.ci_runner === 'self-hosted',
71
+ validate: (v) =>
72
+ LABELS.test(v) ? undefined : 'Enter a JSON array of quoted labels, such as ["self-hosted", "linux"]',
73
+ },
74
+ {
75
+ name: 'docs_review',
76
+ type: 'bool',
77
+ help: 'Let Claude check every pull request for outdated or broken docs? (needs the Claude GitHub App and the CLAUDE_CODE_OAUTH_TOKEN secret)',
78
+ default: true,
79
+ },
80
+ {
81
+ name: 'code_review',
82
+ type: 'bool',
83
+ help: 'Let Claude review pull requests that change code? (needs the same GitHub App and secret)',
84
+ default: true,
85
+ },
86
+ {
87
+ name: 'dual_review',
88
+ type: 'bool',
89
+ help: 'Have Claude Code review each branch before its pull request opens? (uses the Codex CLI if installed, otherwise Claude)',
90
+ default: false,
91
+ },
92
+ {
93
+ name: 'secret_scan',
94
+ type: 'bool',
95
+ help: 'Scan pull requests and pushes to main for leaked secrets? (gitleaks in CI; nothing to install)',
96
+ default: true,
97
+ },
98
+ {
99
+ name: 'acceptance_check',
100
+ type: 'bool',
101
+ help: 'Require a test for every requirement ID a pull request lists under Acceptance? (you set TEST_GLOBS in .claude/harness.conf later)',
102
+ default: false,
103
+ },
104
+ {
105
+ name: 'fail_without_change',
106
+ type: 'bool',
107
+ help: 'Require every new or changed test to fail without the change? (you set FWC_TEST in .claude/harness.conf later)',
108
+ default: false,
109
+ },
110
+ ];
111
+
112
+ // Values the templates use that are derived from the answers, never asked.
113
+ export function computed(answers) {
114
+ return {
115
+ ci_runs_on_default:
116
+ answers.ci_runner === 'self-hosted' ? answers.self_hosted_labels : '["ubuntu-latest"]',
117
+ template_url: TEMPLATE_URL,
118
+ };
119
+ }
120
+
121
+ export function messageAfterCreate(a, commit = { done: false }) {
122
+ const steps = [];
123
+ steps.push(
124
+ commit.done
125
+ ? 'the generated files are committed on main'
126
+ : `commit the generated files on main (the one direct commit that docs/contributing.md allows; not made here: ${commit.reason ?? 'not asked'})`,
127
+ );
128
+ steps.push('add the remote origin, push, and run git remote set-head origin -a');
129
+ steps.push('protect main with scripts/protect-branch.sh');
130
+ steps.push('set up the CI runner');
131
+ if (a.docs_review || a.code_review) steps.push('add the CLAUDE_CODE_OAUTH_TOKEN secret and install the Claude GitHub App');
132
+ if (a.dual_review) steps.push('optionally install and sign in to the Codex CLI');
133
+ steps.push('turn on secret scanning and push protection (Settings, Code security)');
134
+ steps.push('run scripts/check.sh');
135
+ steps.push('run /afe-init in Claude Code to set up the code and its checks');
136
+ return `${a.project_name} is ready. Next:\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}`;
137
+ }
138
+
139
+ // Migrations of an update, by the version that introduces them. `before` runs before the template
140
+ // files are written, `after` after them; both run in the project directory, as bash commands.
141
+ // A migration runs when its version is above the project's recorded version and at most the
142
+ // package's.
143
+ export const MIGRATIONS = [
144
+ // 0.5.0 replaces the seeded .claude/protected-paths, which the update deletes, with
145
+ // .claude/path-classes: its glob lines are kept as class approval, and the file is removed.
146
+ {
147
+ version: '0.5.0',
148
+ before: '[ ! -f .claude/protected-paths ] || cp .claude/protected-paths .claude/protected-paths.pre-0.5',
149
+ after:
150
+ "[ ! -f .claude/protected-paths.pre-0.5 ] || { grep -v -E '^[[:space:]]*(#|$)' .claude/protected-paths.pre-0.5 | sed 's/^/approval /' >> .claude/path-classes; rm -f .claude/protected-paths.pre-0.5 .claude/protected-paths; }",
151
+ },
152
+ ];
package/src/project.js ADDED
@@ -0,0 +1,217 @@
1
+ // Creates a project from the template, and updates a project to the template of this package.
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { UsageError, collectAnswers, compareVersions, readAnswers, writeAnswers } from './answers.js';
8
+ import { LEGACY_ANSWERS_FILE, MIGRATIONS, SEEDED, TASKS, messageAfterCreate } from './config.js';
9
+ import { renderTemplate } from './render.js';
10
+
11
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
+ export const TEMPLATE_DIR = join(ROOT, 'template');
13
+ export const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
14
+
15
+ const OWNED_LIST = '.template/owned.txt';
16
+ const MANIFEST = '.template/owned.sha256';
17
+ const START = '<!-- project:start -->';
18
+ const END = '<!-- project:end -->';
19
+
20
+ function writeFile(dir, rel, { content, mode }) {
21
+ const target = join(dir, rel);
22
+ mkdirSync(dirname(target), { recursive: true });
23
+ writeFileSync(target, content);
24
+ chmodSync(target, mode & 0o777);
25
+ }
26
+
27
+ function run(dir, command, quiet) {
28
+ const r = spawnSync('bash', ['-c', command], {
29
+ cwd: dir,
30
+ stdio: ['ignore', quiet ? 'pipe' : 'inherit', quiet ? 'pipe' : 'inherit'],
31
+ encoding: 'utf8',
32
+ });
33
+ if (r.status !== 0) {
34
+ const output = quiet ? `\n${r.stdout ?? ''}${r.stderr ?? ''}` : '';
35
+ throw new Error(`\`${command}\` failed with exit code ${r.status}${output}`);
36
+ }
37
+ }
38
+
39
+ function runTasks(dir, quiet) {
40
+ for (const task of TASKS) run(dir, task, quiet);
41
+ }
42
+
43
+ function listed(text) {
44
+ return text
45
+ .split('\n')
46
+ .map((l) => l.trim())
47
+ .filter((l) => l && !l.startsWith('#'));
48
+ }
49
+
50
+ /** Lines of AGENTS.md inside its project block; throws unless there is exactly one. */
51
+ function projectBlock(text) {
52
+ const lines = text.split('\n');
53
+ const s = lines.filter((l) => l === START).length;
54
+ const e = lines.filter((l) => l === END).length;
55
+ const si = lines.indexOf(START);
56
+ const ei = lines.indexOf(END);
57
+ if (s !== 1 || e !== 1 || si > ei) {
58
+ throw new UsageError(`AGENTS.md needs exactly one '${START}' line followed by one '${END}' line`);
59
+ }
60
+ return lines.slice(si + 1, ei);
61
+ }
62
+
63
+ function withBlock(rendered, block) {
64
+ const lines = rendered.split('\n');
65
+ const si = lines.indexOf(START);
66
+ const ei = lines.indexOf(END);
67
+ return [...lines.slice(0, si + 1), ...block, ...lines.slice(ei)].join('\n');
68
+ }
69
+
70
+ /** The content that the manifest hash covers, as scripts/check-template.sh computes it. */
71
+ function hashed(rel, text) {
72
+ if (rel !== 'AGENTS.md') return text;
73
+ let skip = false;
74
+ const kept = [];
75
+ for (const line of text.split('\n')) {
76
+ if (line === START) {
77
+ kept.push(line);
78
+ skip = true;
79
+ } else if (line === END) {
80
+ skip = false;
81
+ kept.push(line);
82
+ } else if (!skip) kept.push(line);
83
+ }
84
+ return kept.join('\n');
85
+ }
86
+
87
+ const sha256 = (text) => createHash('sha256').update(text).digest('hex');
88
+
89
+ /** Template-owned files whose content no longer matches the recorded manifest. */
90
+ function editedOwnedFiles(dir) {
91
+ const manifest = join(dir, MANIFEST);
92
+ if (!existsSync(manifest)) return [];
93
+ const edited = [];
94
+ for (const line of listed(readFileSync(manifest, 'utf8'))) {
95
+ const [hash, rel] = line.split(/\s+/, 2);
96
+ const file = join(dir, rel);
97
+ if (existsSync(file) && sha256(hashed(rel, readFileSync(file, 'utf8'))) !== hash) edited.push(rel);
98
+ }
99
+ return edited;
100
+ }
101
+
102
+ function isDirty(dir) {
103
+ try {
104
+ return execFileSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() !== '';
105
+ } catch {
106
+ return false; // not a Git repository
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Creates a project in `dir`.
112
+ * @param {string} dir
113
+ * @param {{ given?: Record<string, unknown>, useDefaults?: boolean, interactive?: boolean,
114
+ * force?: boolean, quiet?: boolean, log?: (msg: string) => void }} options
115
+ * @returns {Promise<{ answers: Record<string, unknown>, message: string }>}
116
+ */
117
+ export async function create(dir, { given, useDefaults, interactive, force = false, quiet = false, git = true, log = () => {} }) {
118
+ const empty = !existsSync(dir) || readdirSync(dir).every((n) => n === '.git');
119
+ if (!empty && !force) {
120
+ throw new UsageError(`${dir} is not empty; choose an empty directory, or pass --force to write into it`);
121
+ }
122
+ const { answers, context } = await collectAnswers({ given, useDefaults, interactive });
123
+ mkdirSync(dir, { recursive: true });
124
+ for (const [rel, file] of renderTemplate(TEMPLATE_DIR, context)) writeFile(dir, rel, file);
125
+ writeAnswers(dir, VERSION, answers);
126
+ log('Writing generated files');
127
+ runTasks(dir, quiet);
128
+ let commit = { done: false, reason: 'skipped with --no-git' };
129
+ if (git) {
130
+ log('Making the initial commit');
131
+ commit = empty ? initialCommit(dir) : { done: false, reason: 'the directory was not empty' };
132
+ }
133
+ return { answers, commit, message: messageAfterCreate(answers, commit) };
134
+ }
135
+
136
+ /**
137
+ * Commits the generated files as the first commit on `main`: the one direct commit to `main` that
138
+ * docs/contributing.md, section 1, allows. Runs only in a new repository, or in one without
139
+ * commits (an empty clone); never throws.
140
+ * @param {string} dir
141
+ * @returns {{ done: boolean, reason?: string }}
142
+ */
143
+ export function initialCommit(dir) {
144
+ const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
145
+ const failed = (r) => r.error || r.status !== 0;
146
+ const why = (r) => (r.error ? r.error.message : (r.stderr || r.stdout || '').trim().split('\n')[0]);
147
+ if (existsSync(join(dir, '.git'))) {
148
+ if (!failed(git('rev-parse', '--verify', '--quiet', 'HEAD'))) return { done: false, reason: 'the repository already has commits' };
149
+ const r = git('symbolic-ref', 'HEAD', 'refs/heads/main');
150
+ if (failed(r)) return { done: false, reason: why(r) };
151
+ } else {
152
+ const r = git('init', '--quiet', '--initial-branch=main');
153
+ if (failed(r)) return { done: false, reason: `git init failed: ${why(r)}` };
154
+ }
155
+ for (const args of [['add', '--all'], ['commit', '--quiet', '-m', `Template: create the project with create-afe-project ${VERSION}`]]) {
156
+ const r = git(...args);
157
+ if (failed(r)) return { done: false, reason: `git ${args[0]} failed: ${why(r)}` };
158
+ }
159
+ return { done: true };
160
+ }
161
+
162
+ /**
163
+ * Updates the project in `dir` to this package's template. Template-owned files are replaced, the
164
+ * project block of AGENTS.md is kept, files the template no longer owns are removed, missing
165
+ * seeded files are created and existing ones never changed, the migrations between the two
166
+ * versions run, and the generated files are rebuilt.
167
+ * @param {string} dir
168
+ * @param {{ given?: Record<string, unknown>, useDefaults?: boolean, interactive?: boolean,
169
+ * allowDirty?: boolean, quiet?: boolean }} options
170
+ */
171
+ export async function update(dir, { given, useDefaults, interactive, allowDirty = false, quiet = false }) {
172
+ const recorded = readAnswers(dir);
173
+ if (!recorded) throw new UsageError(`${dir} has no answers file; is it a project created by create-afe-project?`);
174
+ if (compareVersions(recorded.version, VERSION) > 0) {
175
+ throw new UsageError(`the project follows template ${recorded.version}, newer than this package (${VERSION}); run npx create-afe-project@latest update`);
176
+ }
177
+ if (!allowDirty && isDirty(dir)) {
178
+ throw new UsageError('the working tree has uncommitted changes; commit or stash them, or pass --allow-dirty');
179
+ }
180
+ const agentsFile = join(dir, 'AGENTS.md');
181
+ const block = existsSync(agentsFile) ? projectBlock(readFileSync(agentsFile, 'utf8')) : null;
182
+
183
+ const { answers, context } = await collectAnswers({ given, previous: recorded.answers, useDefaults, interactive });
184
+ const rendered = renderTemplate(TEMPLATE_DIR, context);
185
+ const newOwned = listed(String(rendered.get(OWNED_LIST).content));
186
+ const oldOwnedFile = join(dir, OWNED_LIST);
187
+ const oldOwned = existsSync(oldOwnedFile) ? listed(readFileSync(oldOwnedFile, 'utf8')) : [];
188
+ const edited = editedOwnedFiles(dir);
189
+ const pending = MIGRATIONS.filter(
190
+ (m) => compareVersions(m.version, recorded.version) > 0 && compareVersions(m.version, VERSION) <= 0,
191
+ );
192
+
193
+ for (const m of pending) if (m.before) run(dir, m.before, quiet);
194
+
195
+ const removed = oldOwned.filter((rel) => !newOwned.includes(rel) && existsSync(join(dir, rel)));
196
+ for (const rel of removed) rmSync(join(dir, rel));
197
+
198
+ const seeded = [];
199
+ const unowned = [];
200
+ for (const [rel, file] of rendered) {
201
+ const exists = existsSync(join(dir, rel));
202
+ if (newOwned.includes(rel)) {
203
+ const content = rel === 'AGENTS.md' && block ? withBlock(String(file.content), block) : file.content;
204
+ writeFile(dir, rel, { ...file, content });
205
+ } else if (!exists) {
206
+ writeFile(dir, rel, file);
207
+ (SEEDED.includes(rel) ? seeded : unowned).push(rel);
208
+ }
209
+ }
210
+ writeAnswers(dir, VERSION, answers);
211
+ if (recorded.legacy) rmSync(join(dir, LEGACY_ANSWERS_FILE));
212
+
213
+ for (const m of pending) if (m.after) run(dir, m.after, quiet);
214
+ runTasks(dir, quiet);
215
+
216
+ return { from: recorded.version, to: VERSION, removed, edited, seeded, unowned, migrations: pending.map((m) => m.version), legacy: recorded.legacy };
217
+ }
package/src/render.js ADDED
@@ -0,0 +1,51 @@
1
+ // Renders template/ with the answers: Jinja syntax through Nunjucks, as Copier did. A file whose
2
+ // name ends in .jinja is rendered and loses the suffix; any other file is copied as it is. Every
3
+ // path segment is rendered too, and a segment that renders empty drops the file or directory, so
4
+ // a module's files use a conditional name such as `{% if code_review %}review-code{% endif %}`.
5
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import nunjucks from 'nunjucks';
8
+ import { RENAMES } from './config.js';
9
+
10
+ const SUFFIX = '.jinja';
11
+ const env = new nunjucks.Environment(null, { autoescape: false, throwOnUndefined: true });
12
+
13
+ /**
14
+ * @param {string} text
15
+ * @param {Record<string, unknown>} context
16
+ * @returns {string}
17
+ */
18
+ export function renderString(text, context) {
19
+ return env.renderString(text, context);
20
+ }
21
+
22
+ /**
23
+ * Renders every file of the template directory.
24
+ * @param {string} templateDir
25
+ * @param {Record<string, unknown>} context
26
+ * @returns {Map<string, { content: Buffer | string, mode: number }>} by relative output path
27
+ */
28
+ export function renderTemplate(templateDir, context) {
29
+ const out = new Map();
30
+ const walk = (srcDir, outPrefix) => {
31
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
32
+ const src = join(srcDir, entry.name);
33
+ const templated = entry.isFile() && entry.name.endsWith(SUFFIX);
34
+ let name = renderString(templated ? entry.name.slice(0, -SUFFIX.length) : entry.name, context);
35
+ if (!name) continue;
36
+ if (entry.isDirectory()) {
37
+ walk(src, outPrefix ? `${outPrefix}/${name}` : name);
38
+ continue;
39
+ }
40
+ const { mode } = statSync(src);
41
+ let content = readFileSync(src);
42
+ if (templated) {
43
+ content = renderString(content.toString('utf8'), context);
44
+ }
45
+ name = RENAMES[name] ?? name;
46
+ out.set(outPrefix ? `${outPrefix}/${name}` : name, { content, mode });
47
+ }
48
+ };
49
+ walk(templateDir, '');
50
+ return out;
51
+ }
@@ -0,0 +1,47 @@
1
+ # Harness settings of this project (docs/contributing.md, section 3; docs/testing-rules.md,
2
+ # section 3): one KEY=value per line. Scripts read the values and never run this file; an empty
3
+ # value leaves a setting unset. This file belongs to the project; a change to it is class gate.
4
+
5
+ # Environment commands, run by scripts/env.sh setup|fast|e2e|reset (check is scripts/check.sh):
6
+ # set up a working environment, a fast check, end-to-end tests, return to a known-good state.
7
+ SETUP=
8
+ CHECK_FAST=scripts/check-docs.sh
9
+ E2E=
10
+ RESET=
11
+
12
+ # The check after every edit (.claude/hooks/post-edit-check.sh): a command that gets the changed
13
+ # file as its last argument and takes seconds, such as a formatter check and the linter on that
14
+ # file; and the files it applies to, as shell globs separated by spaces (empty: every file).
15
+ # Empty CHECK_EDIT: no check after edits.
16
+ CHECK_EDIT=
17
+ CHECK_EDIT_GLOBS=
18
+
19
+ # Ralph loop: the most runs of scripts/check.sh before the agent stops and reports the failure.
20
+ RALPH_MAX_ITERATIONS=5
21
+
22
+ # Santa loop (the dual-review skill): the most review rounds for a change of class high before
23
+ # the open blockers go to the owner; 3 to 5.
24
+ REVIEW_MAX_ROUNDS=3
25
+
26
+ # A warning, not a failure, when a pull request changes more lines than this, generated files
27
+ # left out (docs/contributing.md, section 1). Empty: no warning.
28
+ DIFF_WARN_LINES=400
29
+
30
+ # Identifiers that requirements, criteria, and the specifications of checks define, as an
31
+ # extended regular expression. The "Acceptance and test plan" section and the tests carry them.
32
+ ID_PATTERN=(REQ|AC|INV)-[0-9]+
33
+
34
+ # Test files, as shell globs separated by spaces, for example `tests/* src/*_test.go`. Empty: the
35
+ # Acceptance check and the fail-without-change driver skip.
36
+ TEST_GLOBS=
37
+
38
+ # fail-without-change: the files besides the tests to copy onto the base, such as test helpers
39
+ # and runner configuration (shell globs); the command that installs dependencies there (empty:
40
+ # SETUP); the test
41
+ # command, which the driver runs with one test file as its last argument; and an optional
42
+ # command that receives the log file and the test file and exits 0 when the base failed for the
43
+ # expected reason.
44
+ FWC_TOOLCHAIN_GLOBS=
45
+ FWC_INSTALL=
46
+ FWC_TEST=
47
+ FWC_CLASSIFIER=
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse hook (Edit, Write, MultiEdit): the check after every edit (docs/testing-rules.md,
3
+ # section 4). It runs the project's fast per-file check, CHECK_EDIT of .claude/harness.conf, with
4
+ # the changed file as its last argument, and sends a failure back to the agent in the same
5
+ # session (exit 2), before the Stop hooks and CI. CHECK_EDIT_GLOBS (shell globs separated by
6
+ # spaces, * also matches /) limits it to matching files; empty: every file. An empty CHECK_EDIT,
7
+ # a file outside the project, or a deleted file runs nothing. Keep the command to seconds: it runs
8
+ # after every edit.
9
+ set -uo pipefail
10
+ root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
11
+ [ -n "$root" ] || exit 0
12
+ cd "$root" || exit 0
13
+ . scripts/lib/harness-conf.sh 2>/dev/null || exit 0
14
+ cmd=$(conf_get CHECK_EDIT)
15
+ [ -n "$cmd" ] || exit 0
16
+ file=$(node scripts/lib/json.mjs get tool_input.file_path 2>/dev/null) || exit 0
17
+ [ -n "$file" ] && [ -f "$file" ] || exit 0
18
+ root=$(pwd -P)
19
+ abs=$(cd "$(dirname "$file")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$file")") || exit 0
20
+ case "$abs" in "$root"/*) rel=${abs#"$root"/} ;; *) exit 0 ;; esac
21
+
22
+ globs=$(conf_get CHECK_EDIT_GLOBS)
23
+ if [ -n "$globs" ]; then
24
+ match=
25
+ set -f
26
+ for g in $globs; do
27
+ # shellcheck disable=SC2254
28
+ case "$rel" in $g) match=1; break ;; esac
29
+ done
30
+ set +f
31
+ [ -n "$match" ] || exit 0
32
+ fi
33
+
34
+ out=$(bash -c "$cmd \"\$1\"" check-edit "$rel" 2>&1)
35
+ code=$?
36
+ [ "$code" -eq 0 ] && exit 0
37
+ printf 'CHECK_EDIT failed on %s (exit %s); fix it before you go on:\n%s\n' "$rel" "$code" "$(printf '%s\n' "$out" | tail -n 40)" >&2
38
+ exit 2