@frontfactory/brigade 1.0.0 → 1.1.1
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 +42 -5
- package/package.json +1 -1
- package/src/cli.js +33 -7
- package/src/conventions.js +7 -4
- package/src/init.js +14 -108
- package/src/install.js +43 -21
- package/src/options.js +153 -0
package/README.md
CHANGED
|
@@ -42,14 +42,51 @@ detecting it (only `web` for now).
|
|
|
42
42
|
Brigade never touches your `CLAUDE.md`: its instructions live in `.claude/rules/brigade.md`, which Claude Code loads
|
|
43
43
|
at launch like a `CLAUDE.md`.
|
|
44
44
|
|
|
45
|
-
When Brigade is already installed, `init` asks whether to keep the installed version or update to the one you run.
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
When Brigade is already installed, `init` asks whether to keep the installed version or update to the one you run.
|
|
46
|
+
Updating does not repeat the setup: your stored answers are reused and only the options that are new to the manifest
|
|
47
|
+
are asked. On the same version it offers to change your options (every question is asked again, files only change if
|
|
48
|
+
an answer does) or to leave everything as it is. Without a terminal, it updates.
|
|
48
49
|
|
|
49
50
|
An existing file is never overwritten. If it differs from the kit, a `.proposed` file is written next to it; if it is
|
|
50
51
|
identical, nothing happens. A file Brigade wrote and nobody touched is updated. `.gitignore` and `.gitattributes` only
|
|
51
|
-
get the lines they miss. `.claude/context/decisions.md`
|
|
52
|
-
updated nor proposed over.
|
|
52
|
+
get the lines they miss. `.claude/context/decisions.md` and `.claude/context/architecture.md` are written once and then belong to the
|
|
53
|
+
project: they are never updated nor proposed over.
|
|
54
|
+
|
|
55
|
+
## Update
|
|
56
|
+
|
|
57
|
+
Re-run `npx @frontfactory/brigade init`. Kit files you never touched are updated. A file you edited is left as it is
|
|
58
|
+
and a `<file>.proposed` is written next to it: diff it, merge what you want, then delete the `.proposed`.
|
|
59
|
+
|
|
60
|
+
An update does not ask your options again. It reuses the answers stored in `.claude/brigade.json` and asks only the
|
|
61
|
+
options the new version added (your personal reply language is not one of them). A downgrade does the same and keeps
|
|
62
|
+
the answers the older version does not know. To change your options on purpose, run `init` again on the same version
|
|
63
|
+
and choose "Change my options".
|
|
64
|
+
|
|
65
|
+
Brigade records what it did in `.claude/brigade.json`: its version, your answers and the sha256 of each file it wrote.
|
|
66
|
+
That is how it tells an untouched file from an edited one. Do not edit it by hand. Commit it with the rest of `.claude/`:
|
|
67
|
+
the install is shared by the whole team, and anyone can then update it from any machine.
|
|
68
|
+
|
|
69
|
+
## Troubleshooting
|
|
70
|
+
|
|
71
|
+
- `Unknown stack` or `Unknown option` from `init`: only `--yes` (`-y`) and `--stack <name>` exist, and `web` is the only
|
|
72
|
+
stack for now. Run `npx @frontfactory/brigade --help` to see the usage.
|
|
73
|
+
- `.proposed` files keep coming back: a proposal is rewritten only when the kit changed since the last one. Once you have
|
|
74
|
+
merged what you want, delete the `.proposed`.
|
|
75
|
+
- No terminal (CI, pipe): nothing is asked. `init` behaves like `--yes`: a new install accepts every detected answer, an
|
|
76
|
+
existing install is updated with its stored answers, and a new option gets its detected or default value. The run
|
|
77
|
+
prints one line naming the new options and the value used; it never fails for that.
|
|
78
|
+
- "No git repository" warning: run `git init` yourself. Brigade never creates a repository.
|
|
79
|
+
- A broken `.claude/settings.local.json` (invalid JSON) is left untouched, so your personal language is not written
|
|
80
|
+
there. Fix the file and run `init` again.
|
|
81
|
+
|
|
82
|
+
## Security model of the git guard
|
|
83
|
+
|
|
84
|
+
The hook (`templates/web/.claude/hooks/git-guard.mjs`, PreToolUse on Bash) is a safety net against mistakes, not a
|
|
85
|
+
security boundary. It parses the text of the command, so it can miss indirect forms: subshells, `bash -c`, aliases,
|
|
86
|
+
scripts. The real protections are the `permissions` in `settings.json` and branch protection on the remote.
|
|
87
|
+
|
|
88
|
+
"Never read or write secrets" is a rule given to Claude, not an enforcement, beyond the `deny` `Read` rules in
|
|
89
|
+
`settings.json`.
|
|
53
90
|
|
|
54
91
|
## Git
|
|
55
92
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { CONVENTIONS } from './conventions.js';
|
|
2
2
|
import { runInit } from './init.js';
|
|
3
3
|
import { STACKS } from './stacks.js';
|
|
4
|
+
import { VERSION } from './version.js';
|
|
4
5
|
|
|
5
6
|
const HELP = `Usage: brigade init [--yes] [--stack <name>]
|
|
7
|
+
brigade --version
|
|
6
8
|
|
|
7
9
|
--yes accept every detected or default answer, ask nothing
|
|
10
|
+
(an installed project is updated with its stored answers; new options get their default)
|
|
8
11
|
--stack <name> force the stack (${Object.keys(STACKS).join(', ')}); detected otherwise
|
|
12
|
+
-h, --help show this help
|
|
13
|
+
-v, --version show the version
|
|
9
14
|
`;
|
|
10
15
|
|
|
11
16
|
const WORDS = {
|
|
@@ -20,14 +25,18 @@ function parse(args) {
|
|
|
20
25
|
const options = { yes: false, stack: undefined };
|
|
21
26
|
for (let i = 0; i < args.length; i++) {
|
|
22
27
|
if (args[i] === '--yes' || args[i] === '-y') options.yes = true;
|
|
23
|
-
else if (args[i] === '--stack')
|
|
28
|
+
else if (args[i] === '--stack') {
|
|
29
|
+
const value = args[++i];
|
|
30
|
+
if (!value || value.startsWith('-')) throw new Error(`--stack needs a value (${Object.keys(STACKS).join(', ')})`);
|
|
31
|
+
options.stack = value;
|
|
32
|
+
}
|
|
24
33
|
else throw new Error(`Unknown option: ${args[i]}`);
|
|
25
34
|
}
|
|
26
35
|
if (options.stack && !STACKS[options.stack]) throw new Error(`Unknown stack: ${options.stack}`);
|
|
27
36
|
return options;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
function summary({ version, answers, report, ignored, repo, personal, local }) {
|
|
39
|
+
function summary({ version, answers, report, ignored, repo, personal, local, asked = [] }) {
|
|
31
40
|
const lines = report.map(({ path, status }) => `${WORDS[status].padEnd(28)} ${path}${status === 'proposed' ? '.proposed' : ''}`);
|
|
32
41
|
for (const { path, status, added } of ignored) {
|
|
33
42
|
if (status === 'updated') lines.push(`${'lines added'.padEnd(28)} ${path} (${added.join(', ')})`);
|
|
@@ -44,7 +53,8 @@ function summary({ version, answers, report, ignored, repo, personal, local }) {
|
|
|
44
53
|
`Documentation ${answers.docsLanguage}`,
|
|
45
54
|
`Replies for you ${personal.language && local?.status === 'written' ? `${personal.language} (your machine only)` : 'project default'}`
|
|
46
55
|
];
|
|
47
|
-
|
|
56
|
+
const added = asked.flat().map((key) => `${key}: ${answers[key]}`).join(', ');
|
|
57
|
+
return { files: lines.join('\n'), settings: settings.join('\n'), added, kept: report.some(({ status }) => status === 'proposed'), repo };
|
|
48
58
|
}
|
|
49
59
|
|
|
50
60
|
function print(result, interactive) {
|
|
@@ -55,25 +65,41 @@ function print(result, interactive) {
|
|
|
55
65
|
return;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
const { files, settings, kept, repo } = summary(result);
|
|
68
|
+
const { files, settings, added, kept, repo } = summary(result);
|
|
59
69
|
|
|
60
70
|
if (!interactive) {
|
|
61
|
-
|
|
71
|
+
const news = added ? `\n\nNew options, default used: ${added}` : '';
|
|
72
|
+
console.log(`Brigade installed (stack: ${result.stack}).\n\n${files}\n\n${settings}${news}\n\nCommit .claude/ including .claude/brigade.json so the install is shared with your team and can be updated from any machine.`);
|
|
62
73
|
return;
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
prompter.note(files, 'Files');
|
|
66
77
|
prompter.note(settings, 'Your setup');
|
|
78
|
+
if (added) prompter.info(`New options set up: ${added}`);
|
|
67
79
|
if (kept) prompter.warn('Some files of yours differ from the kit: they were kept. Merge the .proposed version if you want it.');
|
|
68
80
|
if (!repo) prompter.warn('Remember to run `git init` before your first commit.');
|
|
69
|
-
prompter.outro('Done. Run `claude` here, use Plan Mode (Shift+Tab) to discuss, and /brigade-language and /brigade-effort for your own settings.');
|
|
81
|
+
prompter.outro('Done. Commit `.claude/` with `.claude/brigade.json` to share the install with your team: anyone can then update it from any machine. Run `claude` here, use Plan Mode (Shift+Tab) to discuss, and /brigade-language and /brigade-effort for your own settings.');
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
const isHelp = (arg) => arg === '--help' || arg === '-h';
|
|
85
|
+
|
|
72
86
|
export async function main(argv) {
|
|
73
87
|
const [command, ...rest] = argv;
|
|
88
|
+
if (command === undefined || command === 'help' || isHelp(command)) {
|
|
89
|
+
console.log(HELP);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
if (command === '--version' || command === '-v') {
|
|
93
|
+
console.log(VERSION);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
74
96
|
if (command !== 'init') {
|
|
97
|
+
console.error(`brigade: Unknown command: ${command}\n\n${HELP}`);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
if (rest.some(isHelp)) {
|
|
75
101
|
console.log(HELP);
|
|
76
|
-
return
|
|
102
|
+
return 0;
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
try {
|
package/src/conventions.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
+
// Shared by every convention text.
|
|
2
|
+
const LIMIT = '72 characters max.';
|
|
3
|
+
|
|
1
4
|
export const CONVENTIONS = {
|
|
2
5
|
conventional: {
|
|
3
6
|
label: 'Conventional Commits',
|
|
4
7
|
example: 'feat(auth): add login form',
|
|
5
|
-
format: 'Conventional Commits: `type(scope): description` (`feat`, `fix`, `chore`, `refactor`, `docs`, `test`), imperative mood,
|
|
8
|
+
format: 'Conventional Commits: `type(scope): description` (`feat`, `fix`, `chore`, `refactor`, `docs`, `test`), imperative mood, ' + LIMIT
|
|
6
9
|
},
|
|
7
10
|
angular: {
|
|
8
11
|
label: 'Angular',
|
|
9
12
|
example: 'fix(core): handle empty list',
|
|
10
|
-
format: 'Angular: `type(scope): description` with the types `build`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `test`, imperative mood,
|
|
13
|
+
format: 'Angular: `type(scope): description` with the types `build`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `test`, imperative mood, ' + LIMIT
|
|
11
14
|
},
|
|
12
15
|
gitmoji: {
|
|
13
16
|
label: 'Gitmoji',
|
|
14
17
|
example: '✨ Add login form',
|
|
15
|
-
format: 'Gitmoji: `<emoji> Description` (e.g. `✨ Add login form`), imperative mood,
|
|
18
|
+
format: 'Gitmoji: `<emoji> Description` (e.g. `✨ Add login form`), imperative mood, ' + LIMIT
|
|
16
19
|
},
|
|
17
20
|
imperative: {
|
|
18
21
|
label: 'Simple imperative',
|
|
19
22
|
example: 'Add login form',
|
|
20
|
-
format: 'A single imperative sentence without prefix (e.g. `Add login form`),
|
|
23
|
+
format: 'A single imperative sentence without prefix (e.g. `Add login form`), ' + LIMIT
|
|
21
24
|
}
|
|
22
25
|
};
|
package/src/init.js
CHANGED
|
@@ -1,111 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
detectDefaultBranch,
|
|
5
|
-
detectLanguage,
|
|
6
|
-
detectPackageManager,
|
|
7
|
-
isGitRepo,
|
|
8
|
-
localBranches
|
|
9
|
-
} from './detect.js';
|
|
10
|
-
import { appendMissingLines, installFiles, readManifest, writeLocalSettings, writeManifest } from './install.js';
|
|
1
|
+
import { isGitRepo } from './detect.js';
|
|
2
|
+
import { appendMissingLines, hasKitFiles, installFiles, MANIFEST, readManifest, writeLocalSettings, writeManifest } from './install.js';
|
|
3
|
+
import { askOptions } from './options.js';
|
|
11
4
|
import { createPrompter } from './prompt.js';
|
|
12
5
|
import { DEFAULT_STACK, STACKS, detectStack } from './stacks.js';
|
|
13
6
|
import { VERSION, compareVersions } from './version.js';
|
|
14
7
|
|
|
15
|
-
const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
16
|
-
const LANGUAGES = ['English', 'French'];
|
|
17
|
-
|
|
18
|
-
// Every answer is detected first, then validated by the dev. A previous answer wins over detection.
|
|
19
|
-
async function ask({ cwd, prompter, previous = {} }) {
|
|
20
|
-
const { validate, choose, text } = prompter;
|
|
21
|
-
const branches = localBranches(cwd);
|
|
22
|
-
|
|
23
|
-
const defaultBranch = await validate(
|
|
24
|
-
'Default branch of the repository',
|
|
25
|
-
previous.defaultBranch || detectDefaultBranch(cwd),
|
|
26
|
-
() => text('Default branch of the repository', 'main')
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
const baseCandidates = [previous.baseBranch, defaultBranch, branches.includes('develop') && 'develop'].filter(Boolean);
|
|
30
|
-
const baseBranch = await choose(
|
|
31
|
-
'Reference branch of the work',
|
|
32
|
-
[...new Set(baseCandidates)].map((value) => ({
|
|
33
|
-
value,
|
|
34
|
-
label: value,
|
|
35
|
-
hint: value === defaultBranch ? 'default branch' : 'feature branches start from it'
|
|
36
|
-
}))
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
const manager = detectPackageManager(cwd);
|
|
40
|
-
const managers = [...new Set([...manager.candidates, ...PACKAGE_MANAGERS])];
|
|
41
|
-
const packageManager = await validate(
|
|
42
|
-
'Package manager',
|
|
43
|
-
previous.packageManager || manager.value,
|
|
44
|
-
() => choose(
|
|
45
|
-
'Package manager',
|
|
46
|
-
managers.map((value) => ({ value, label: value, hint: manager.candidates.includes(value) ? 'found in the project' : undefined })),
|
|
47
|
-
{ other: false }
|
|
48
|
-
)
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const conventionKey = await validate(
|
|
52
|
-
'Commit convention',
|
|
53
|
-
previous.commitConvention || detectCommitConvention(cwd) || 'conventional',
|
|
54
|
-
() => choose(
|
|
55
|
-
'Commit convention',
|
|
56
|
-
[
|
|
57
|
-
...Object.entries(CONVENTIONS).map(([value, { label, example }]) => ({ value, label, hint: example })),
|
|
58
|
-
{ value: 'custom', label: 'Custom', hint: 'you describe it in one sentence' }
|
|
59
|
-
],
|
|
60
|
-
{ other: false }
|
|
61
|
-
),
|
|
62
|
-
CONVENTIONS[previous.commitConvention || detectCommitConvention(cwd) || 'conventional']?.label
|
|
63
|
-
);
|
|
64
|
-
const commitFormat = conventionKey === 'custom'
|
|
65
|
-
? await text('Describe your commit format in one sentence', previous.commitFormat)
|
|
66
|
-
: CONVENTIONS[conventionKey].format;
|
|
67
|
-
|
|
68
|
-
const languages = (current) => [...new Set([current, ...LANGUAGES].filter(Boolean))].map((value) => ({ value, label: value }));
|
|
69
|
-
|
|
70
|
-
const replyLanguage = await validate(
|
|
71
|
-
"Default language of Claude's replies for this project (each developer can use their own later)",
|
|
72
|
-
previous.replyLanguage || detectLanguage(),
|
|
73
|
-
() => choose("Default language of Claude's replies for this project", languages())
|
|
74
|
-
);
|
|
75
|
-
const mine = await choose(
|
|
76
|
-
'And for you? This stays on your machine',
|
|
77
|
-
[
|
|
78
|
-
{ value: 'keep', label: `Keep the project language (${replyLanguage || LANGUAGES[0]})` },
|
|
79
|
-
{ value: 'own', label: 'Use another language for me' }
|
|
80
|
-
],
|
|
81
|
-
{ other: false }
|
|
82
|
-
);
|
|
83
|
-
const ownLanguage = mine === 'own'
|
|
84
|
-
? await choose('Your own reply language', languages().filter(({ value }) => value !== replyLanguage))
|
|
85
|
-
: '';
|
|
86
|
-
|
|
87
|
-
const codeLanguage = await choose(
|
|
88
|
-
'Language of the code, comments and commit messages (shared by the team)',
|
|
89
|
-
languages(previous.codeLanguage)
|
|
90
|
-
);
|
|
91
|
-
const docsLanguage = await choose(
|
|
92
|
-
'Language of the documentation (shared by the team)',
|
|
93
|
-
languages(previous.docsLanguage || codeLanguage)
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
const answers = {
|
|
97
|
-
defaultBranch,
|
|
98
|
-
baseBranch,
|
|
99
|
-
packageManager,
|
|
100
|
-
commitConvention: conventionKey,
|
|
101
|
-
commitFormat,
|
|
102
|
-
replyLanguage: replyLanguage || LANGUAGES[0],
|
|
103
|
-
codeLanguage,
|
|
104
|
-
docsLanguage
|
|
105
|
-
};
|
|
106
|
-
return { answers, personal: { language: ownLanguage } };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
8
|
// Asked whenever Brigade is already installed. The first option is the answer when nothing can be asked.
|
|
110
9
|
async function chooseVersion({ prompter, installed, version }) {
|
|
111
10
|
if (installed === version) {
|
|
@@ -125,7 +24,7 @@ async function chooseVersion({ prompter, installed, version }) {
|
|
|
125
24
|
return prompter.choose(
|
|
126
25
|
newer ? 'A newer version is available' : 'This Brigade is older than the installed one',
|
|
127
26
|
[
|
|
128
|
-
{ value: 'update', label: `${newer ? 'Update' : 'Switch'} to ${version}`, hint: 'new kit files, and
|
|
27
|
+
{ value: 'update', label: `${newer ? 'Update' : 'Switch'} to ${version}`, hint: 'new kit files, your answers are kept and only new options are asked. Files you edited are kept' },
|
|
129
28
|
{ value: 'keep', label: `Keep ${installed}`, hint: 'nothing is written' }
|
|
130
29
|
],
|
|
131
30
|
{ other: false }
|
|
@@ -143,19 +42,26 @@ export async function runInit({ cwd, yes = false, stack, stackDir, version = VER
|
|
|
143
42
|
prompter.info(`Stack: ${name}${stack ? '' : ' (the only one available for now)'}.`);
|
|
144
43
|
|
|
145
44
|
const installed = previous?.version;
|
|
146
|
-
|
|
45
|
+
const choice = installed && await chooseVersion({ prompter, installed, version });
|
|
46
|
+
if (choice === 'keep') {
|
|
147
47
|
return { kept: installed, stack: previous.stack ?? name, answers: previous.answers, report: [], ignored: [], repo, prompter, personal: {}, local: null };
|
|
148
48
|
}
|
|
149
49
|
|
|
150
50
|
if (!repo) {
|
|
151
51
|
prompter.warn('No git repository here. Brigade will not create one: run `git init` when you are ready. The branch names below are kept for then.');
|
|
152
52
|
}
|
|
153
|
-
|
|
53
|
+
if (!previous && hasKitFiles({ cwd, stackDir: dir })) {
|
|
54
|
+
prompter.warn(`${MANIFEST} is missing but Brigade files already exist here. Nothing is overwritten: files identical to the kit are adopted and files that differ get a .proposed. If the manifest exists in your team's repo, restore it from git first.`);
|
|
55
|
+
}
|
|
56
|
+
// A version change replays the kit with the stored answers and only asks the missing options. Otherwise (first
|
|
57
|
+
// install, same version) every question is asked.
|
|
58
|
+
const update = choice === 'update' && installed !== version;
|
|
59
|
+
const { answers, personal, asked } = await askOptions({ cwd, prompter, previous: previous?.answers ?? {}, full: !update });
|
|
154
60
|
|
|
155
61
|
const { report, hashes } = installFiles({ cwd, stackDir: dir, answers, previous });
|
|
156
62
|
writeManifest({ cwd, version, stack: name, answers, hashes });
|
|
157
63
|
const ignored = appendMissingLines(cwd);
|
|
158
64
|
const local = personal.language ? writeLocalSettings(cwd, { language: personal.language }) : null;
|
|
159
65
|
|
|
160
|
-
return { version, stack: name, answers, report, ignored, repo, prompter, personal, local };
|
|
66
|
+
return { version, stack: name, answers, report, ignored, repo, prompter, personal, local, asked };
|
|
161
67
|
}
|
package/src/install.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, relative } from 'node:path';
|
|
4
4
|
|
|
5
5
|
export const MANIFEST = '.claude/brigade.json';
|
|
@@ -11,9 +11,10 @@ const APPENDS = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
// Written once, then the team's own: never updated, never proposed over.
|
|
14
|
-
const WRITE_ONCE = new Set(['.claude/context/decisions.md']);
|
|
14
|
+
const WRITE_ONCE = new Set(['.claude/context/decisions.md', '.claude/context/architecture.md']);
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Line endings do not count: a CRLF checkout of a kit file is the same file.
|
|
17
|
+
export const sha = (content) => createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
|
|
17
18
|
|
|
18
19
|
function listFiles(dir) {
|
|
19
20
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
@@ -23,6 +24,11 @@ function listFiles(dir) {
|
|
|
23
24
|
});
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// True when the project already holds at least one file of the kit.
|
|
28
|
+
export function hasKitFiles({ cwd, stackDir }) {
|
|
29
|
+
return listFiles(stackDir).some((source) => existsSync(join(cwd, relative(stackDir, source))));
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
export function render(content, answers) {
|
|
27
33
|
const values = {
|
|
28
34
|
DEFAULT_BRANCH: answers.defaultBranch,
|
|
@@ -36,59 +42,73 @@ export function render(content, answers) {
|
|
|
36
42
|
return content.replace(/__([A-Z_]+)__/g, (match, key) => values[key] ?? match);
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
// A missing manifest means a first install. An unreadable one is an error: guessing would overwrite the install.
|
|
39
46
|
export function readManifest(cwd) {
|
|
47
|
+
const file = join(cwd, MANIFEST);
|
|
48
|
+
if (!existsSync(file)) return null;
|
|
40
49
|
try {
|
|
41
|
-
|
|
50
|
+
const manifest = JSON.parse(readFileSync(file, 'utf8'));
|
|
51
|
+
if (manifest && typeof manifest === 'object' && !Array.isArray(manifest)) return manifest;
|
|
42
52
|
} catch {
|
|
43
|
-
|
|
53
|
+
// Reported below, same as a valid JSON of the wrong shape.
|
|
44
54
|
}
|
|
55
|
+
throw new Error(`${MANIFEST} is corrupted. Fix it or delete it to start over (Brigade will then treat this as a first install).`);
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
// Never overwrites a file that differs from what Brigade last wrote: that one gets a `.proposed` next to it.
|
|
59
|
+
// Everything is planned first (reads only), then written, so an error while planning leaves the project untouched.
|
|
48
60
|
export function installFiles({ cwd, stackDir, answers, previous }) {
|
|
49
61
|
const report = [];
|
|
50
62
|
const hashes = {};
|
|
63
|
+
const writes = [];
|
|
64
|
+
const removals = [];
|
|
51
65
|
|
|
52
66
|
for (const source of listFiles(stackDir)) {
|
|
53
67
|
const path = relative(stackDir, source).split('\\').join('/');
|
|
54
68
|
const target = join(cwd, path);
|
|
55
69
|
const content = render(readFileSync(source, 'utf8'), answers);
|
|
56
70
|
const mode = statSync(source).mode & 0o777;
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
writeFileSync(file, content, { mode });
|
|
61
|
-
chmodSync(file, mode);
|
|
62
|
-
};
|
|
71
|
+
const wanted = sha(content);
|
|
72
|
+
const proposed = `${target}.proposed`;
|
|
73
|
+
const write = (file) => writes.push({ file, content, mode });
|
|
63
74
|
|
|
64
75
|
if (!existsSync(target)) {
|
|
65
76
|
write(target);
|
|
66
|
-
hashes[path] =
|
|
77
|
+
hashes[path] = wanted;
|
|
67
78
|
report.push({ path, status: 'created' });
|
|
68
79
|
continue;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
const current = sha(readFileSync(target, 'utf8'));
|
|
72
83
|
if (WRITE_ONCE.has(path)) {
|
|
73
|
-
report.push({ path, status: current ===
|
|
84
|
+
report.push({ path, status: current === wanted ? 'unchanged' : 'kept' });
|
|
74
85
|
continue;
|
|
75
86
|
}
|
|
76
|
-
if (current ===
|
|
87
|
+
if (current === wanted) {
|
|
88
|
+
// The target caught up with the kit: an old proposal is stale.
|
|
89
|
+
if (existsSync(proposed)) removals.push(proposed);
|
|
77
90
|
hashes[path] = current;
|
|
78
91
|
report.push({ path, status: 'unchanged' });
|
|
79
92
|
} else if (previous?.files?.[path] === current) {
|
|
80
93
|
write(target);
|
|
81
|
-
|
|
94
|
+
if (existsSync(proposed)) removals.push(proposed);
|
|
95
|
+
hashes[path] = wanted;
|
|
82
96
|
report.push({ path, status: 'updated' });
|
|
83
97
|
} else {
|
|
84
|
-
const
|
|
85
|
-
const same = existsSync(proposed) && sha(readFileSync(proposed, 'utf8')) === sha(content);
|
|
98
|
+
const same = existsSync(proposed) && sha(readFileSync(proposed, 'utf8')) === wanted;
|
|
86
99
|
if (!same) write(proposed);
|
|
87
100
|
if (previous?.files?.[path]) hashes[path] = previous.files[path];
|
|
88
101
|
report.push({ path, status: 'proposed' });
|
|
89
102
|
}
|
|
90
103
|
}
|
|
91
104
|
|
|
105
|
+
for (const { file, content, mode } of writes) {
|
|
106
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
107
|
+
writeFileSync(file, content, { mode });
|
|
108
|
+
chmodSync(file, mode);
|
|
109
|
+
}
|
|
110
|
+
for (const file of removals) rmSync(file, { force: true });
|
|
111
|
+
|
|
92
112
|
return { report, hashes };
|
|
93
113
|
}
|
|
94
114
|
|
|
@@ -98,14 +118,16 @@ export function appendMissingLines(cwd) {
|
|
|
98
118
|
return Object.entries(APPENDS).map(([path, lines]) => {
|
|
99
119
|
const file = join(cwd, path);
|
|
100
120
|
const existing = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
101
|
-
const known = new Set(existing.split(
|
|
121
|
+
const known = new Set(existing.split(/\r?\n/).map(normalize));
|
|
102
122
|
const missing = lines.filter((line) => !known.has(normalize(line)));
|
|
103
123
|
|
|
104
124
|
if (missing.length === 0) return { path, status: 'unchanged' };
|
|
105
125
|
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
126
|
+
// Keep the file's own line endings.
|
|
127
|
+
const eol = existing.includes('\r\n') ? '\r\n' : '\n';
|
|
128
|
+
const separator = existing === '' || existing.endsWith('\n') ? '' : eol;
|
|
129
|
+
const heading = existing === '' ? '' : eol;
|
|
130
|
+
writeFileSync(file, `${existing}${separator}${heading}## Claude${eol}${missing.join(eol)}${eol}`);
|
|
109
131
|
return { path, status: 'updated', added: missing };
|
|
110
132
|
});
|
|
111
133
|
}
|
package/src/options.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { CONVENTIONS } from './conventions.js';
|
|
2
|
+
import {
|
|
3
|
+
detectCommitConvention,
|
|
4
|
+
detectDefaultBranch,
|
|
5
|
+
detectLanguage,
|
|
6
|
+
detectPackageManager,
|
|
7
|
+
localBranches
|
|
8
|
+
} from './detect.js';
|
|
9
|
+
|
|
10
|
+
const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
11
|
+
const LANGUAGES = ['English', 'French'];
|
|
12
|
+
|
|
13
|
+
const languages = (current) => [...new Set([current, ...LANGUAGES].filter(Boolean))].map((value) => ({ value, label: value }));
|
|
14
|
+
|
|
15
|
+
// The options of the kit, in the order they are asked. `keys` are the names stored in the manifest `answers`: a group
|
|
16
|
+
// is missing when any of its keys is. `ask(ctx)` returns the values of its keys. Every answer is detected first, then
|
|
17
|
+
// validated by the dev; a previous answer wins over detection.
|
|
18
|
+
// ctx: { cwd, prompter, previous (stored answers), answers (decided so far), full (asked from scratch), personal }
|
|
19
|
+
export const OPTIONS = [
|
|
20
|
+
{
|
|
21
|
+
keys: ['defaultBranch'],
|
|
22
|
+
ask: async ({ cwd, prompter: { validate, text }, previous }) => ({
|
|
23
|
+
defaultBranch: await validate(
|
|
24
|
+
'Default branch of the repository',
|
|
25
|
+
previous.defaultBranch || detectDefaultBranch(cwd),
|
|
26
|
+
() => text('Default branch of the repository', 'main')
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
keys: ['baseBranch'],
|
|
32
|
+
ask: async ({ cwd, prompter: { choose }, previous, answers }) => {
|
|
33
|
+
const { defaultBranch } = answers;
|
|
34
|
+
const baseCandidates = [previous.baseBranch, defaultBranch, localBranches(cwd).includes('develop') && 'develop'].filter(Boolean);
|
|
35
|
+
return {
|
|
36
|
+
baseBranch: await choose(
|
|
37
|
+
'Reference branch of the work',
|
|
38
|
+
[...new Set(baseCandidates)].map((value) => ({
|
|
39
|
+
value,
|
|
40
|
+
label: value,
|
|
41
|
+
hint: value === defaultBranch ? 'default branch' : 'feature branches start from it'
|
|
42
|
+
}))
|
|
43
|
+
)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
keys: ['packageManager'],
|
|
49
|
+
ask: async ({ cwd, prompter: { validate, choose }, previous }) => {
|
|
50
|
+
const manager = detectPackageManager(cwd);
|
|
51
|
+
const managers = [...new Set([...manager.candidates, ...PACKAGE_MANAGERS])];
|
|
52
|
+
return {
|
|
53
|
+
packageManager: await validate(
|
|
54
|
+
'Package manager',
|
|
55
|
+
previous.packageManager || manager.value,
|
|
56
|
+
() => choose(
|
|
57
|
+
'Package manager',
|
|
58
|
+
managers.map((value) => ({ value, label: value, hint: manager.candidates.includes(value) ? 'found in the project' : undefined })),
|
|
59
|
+
{ other: false }
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
keys: ['commitConvention', 'commitFormat'],
|
|
67
|
+
ask: async ({ cwd, prompter: { validate, choose, text }, previous }) => {
|
|
68
|
+
const start = previous.commitConvention || detectCommitConvention(cwd) || 'conventional';
|
|
69
|
+
const commitConvention = await validate(
|
|
70
|
+
'Commit convention',
|
|
71
|
+
start,
|
|
72
|
+
() => choose(
|
|
73
|
+
'Commit convention',
|
|
74
|
+
[
|
|
75
|
+
...Object.entries(CONVENTIONS).map(([value, { label, example }]) => ({ value, label, hint: example })),
|
|
76
|
+
{ value: 'custom', label: 'Custom', hint: 'you describe it in one sentence' }
|
|
77
|
+
],
|
|
78
|
+
{ other: false }
|
|
79
|
+
),
|
|
80
|
+
CONVENTIONS[start]?.label
|
|
81
|
+
);
|
|
82
|
+
const commitFormat = commitConvention === 'custom'
|
|
83
|
+
? await text('Describe your commit format in one sentence', previous.commitFormat)
|
|
84
|
+
: CONVENTIONS[commitConvention].format;
|
|
85
|
+
return { commitConvention, commitFormat };
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
keys: ['replyLanguage'],
|
|
90
|
+
ask: async (ctx) => {
|
|
91
|
+
const { prompter: { validate, choose }, previous } = ctx;
|
|
92
|
+
const replyLanguage = await validate(
|
|
93
|
+
"Default language of Claude's replies for this project (each developer can use their own later)",
|
|
94
|
+
previous.replyLanguage || detectLanguage(),
|
|
95
|
+
() => choose("Default language of Claude's replies for this project", languages())
|
|
96
|
+
);
|
|
97
|
+
// The personal choice lives in settings.local.json, not in the manifest: only asked from scratch.
|
|
98
|
+
if (ctx.full) {
|
|
99
|
+
const mine = await choose(
|
|
100
|
+
'And for you? This stays on your machine',
|
|
101
|
+
[
|
|
102
|
+
{ value: 'keep', label: `Keep the project language (${replyLanguage || LANGUAGES[0]})` },
|
|
103
|
+
{ value: 'own', label: 'Use another language for me' }
|
|
104
|
+
],
|
|
105
|
+
{ other: false }
|
|
106
|
+
);
|
|
107
|
+
if (mine === 'own') {
|
|
108
|
+
ctx.personal.language = await choose(
|
|
109
|
+
'Your own reply language',
|
|
110
|
+
languages().filter(({ value }) => value !== replyLanguage)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { replyLanguage: replyLanguage || LANGUAGES[0] };
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
keys: ['codeLanguage', 'docsLanguage'],
|
|
119
|
+
ask: async ({ prompter: { choose }, previous }) => {
|
|
120
|
+
const codeLanguage = await choose(
|
|
121
|
+
'Language of the code, comments and commit messages (shared by the team)',
|
|
122
|
+
languages(previous.codeLanguage)
|
|
123
|
+
);
|
|
124
|
+
const docsLanguage = await choose(
|
|
125
|
+
'Language of the documentation (shared by the team)',
|
|
126
|
+
languages(previous.docsLanguage || codeLanguage)
|
|
127
|
+
);
|
|
128
|
+
return { codeLanguage, docsLanguage };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
// `previous`: the stored answers. Returns the options (groups of keys) that have no stored value yet.
|
|
134
|
+
export const missingOptions = (previous = {}) => OPTIONS.filter(({ keys }) => keys.some((key) => previous[key] === undefined));
|
|
135
|
+
|
|
136
|
+
// Asks the options in order. From scratch (`full`) every option is asked; otherwise only the missing ones and the
|
|
137
|
+
// others keep their stored answers. Keys the kit does not know (older version) are kept as they are.
|
|
138
|
+
export async function askOptions({ cwd, prompter, previous = {}, full }) {
|
|
139
|
+
const missing = new Set(missingOptions(previous));
|
|
140
|
+
const answers = {};
|
|
141
|
+
const personal = {};
|
|
142
|
+
const asked = [];
|
|
143
|
+
|
|
144
|
+
for (const option of OPTIONS) {
|
|
145
|
+
if (full || missing.has(option)) {
|
|
146
|
+
Object.assign(answers, await option.ask({ cwd, prompter, previous, answers, full, personal }));
|
|
147
|
+
if (!full) asked.push(option.keys);
|
|
148
|
+
} else {
|
|
149
|
+
for (const key of option.keys) answers[key] = previous[key];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { answers: { ...previous, ...answers }, personal, asked };
|
|
153
|
+
}
|