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.
- package/CHANGELOG.md +351 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/create-afe-project.js +123 -0
- package/package.json +43 -4
- package/src/answers.js +147 -0
- package/src/config.js +152 -0
- package/src/project.js +217 -0
- package/src/render.js +51 -0
- package/template/.claude/harness.conf +47 -0
- package/template/.claude/hooks/post-edit-check.sh +38 -0
- package/template/.claude/hooks/protect-docs.sh +315 -0
- package/template/.claude/hooks/stop-check-decisions.sh +25 -0
- package/template/.claude/hooks/stop-check-docs.sh +29 -0
- package/template/.claude/hooks/stop-check-template.sh +23 -0
- package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
- package/template/.claude/path-classes +61 -0
- package/template/.claude/settings.project.json +5 -0
- package/template/.claude/settings.template.json +76 -0
- package/template/.claude/skills/afe-init/SKILL.md +104 -0
- package/template/.claude/skills/create-pr/SKILL.md.jinja +107 -0
- package/template/.claude/skills/update-template/SKILL.md +40 -0
- package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +46 -0
- package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +56 -0
- package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
- package/template/.github/workflows/decisions.yml.jinja +47 -0
- package/template/.github/workflows/docs.yml.jinja +40 -0
- package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +51 -0
- package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +49 -0
- package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +40 -0
- package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
- package/template/.markdownlint-cli2.jsonc +27 -0
- package/template/.template/owned.txt.jinja +63 -0
- package/template/AGENTS.md.jinja +91 -0
- package/template/CLAUDE.md +3 -0
- package/template/README.md.jinja +41 -0
- package/template/_gitignore +14 -0
- package/template/docs/README.md.jinja +44 -0
- package/template/docs/contributing.md.jinja +206 -0
- package/template/docs/decisions/adr-template.md +38 -0
- package/template/docs/documentation-rules.md.jinja +200 -0
- package/template/docs/testing-rules.md +80 -0
- package/template/scripts/check-decisions.sh +382 -0
- package/template/scripts/check-docs.sh +20 -0
- package/template/scripts/check-links.mjs +142 -0
- package/template/scripts/check-project.sh +27 -0
- package/template/scripts/check-template.sh +88 -0
- package/template/scripts/check.sh +21 -0
- package/template/scripts/env.sh +29 -0
- package/template/scripts/gen-decisions-index.sh +57 -0
- package/template/scripts/gen-settings.sh +14 -0
- package/template/scripts/lib/classes.sh +89 -0
- package/template/scripts/lib/git-base.sh +23 -0
- package/template/scripts/lib/harness-conf.sh +48 -0
- package/template/scripts/lib/json.mjs +160 -0
- package/template/scripts/pr-metrics.sh +80 -0
- package/template/scripts/protect-branch.sh +53 -0
- package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
- 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
|