create-agent-rig 0.4.0 → 0.6.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 +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { settingsForInstalledHooks } from '../lib/init-settings.js';
|
|
4
|
+
import { mapConcurrent } from '../lib/copy-tree.js';
|
|
4
5
|
import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
|
|
5
6
|
import { substituteContent } from '../lib/substitute.js';
|
|
6
7
|
import { agentOsInitDir, agentOsUniversalDir } from '../templates.js';
|
|
@@ -8,7 +9,11 @@ import { packageVersion } from '../lib/version.js';
|
|
|
8
9
|
/** A user-facing failure: message is printed as-is, no stack trace. */
|
|
9
10
|
export class InitError extends Error {
|
|
10
11
|
}
|
|
12
|
+
/** What `--force` answers with now that `upgrade` owns the case it stood in for. */
|
|
13
|
+
export const FORCE_DEPRECATED = 'deprecated — init --force replaced only CLAUDE.md; run create-agent-rig upgrade instead';
|
|
11
14
|
const SETTINGS = '.claude/settings.json';
|
|
15
|
+
const CODEX_HOOKS = '.codex/hooks.json';
|
|
16
|
+
const MAPS = ['CLAUDE.md', 'AGENTS.md'];
|
|
12
17
|
async function loadManifest() {
|
|
13
18
|
const raw = await readFile(path.join(agentOsUniversalDir(), 'layers.json'), 'utf8');
|
|
14
19
|
return JSON.parse(raw);
|
|
@@ -53,15 +58,15 @@ export async function initManifest() {
|
|
|
53
58
|
const manifest = await loadManifest();
|
|
54
59
|
const universal = agentOsUniversalDir();
|
|
55
60
|
const override = agentOsInitDir();
|
|
56
|
-
const files = []
|
|
57
|
-
for (const rel of [...manifest.process, 'CLAUDE.md']) {
|
|
61
|
+
const files = await mapConcurrent([...manifest.process, ...MAPS], 16, async (rel) => {
|
|
58
62
|
const overridden = path.join(override, rel);
|
|
59
|
-
|
|
63
|
+
return {
|
|
60
64
|
rel,
|
|
61
65
|
source: (await exists(overridden)) ? overridden : path.join(universal, rel),
|
|
62
|
-
}
|
|
63
|
-
}
|
|
66
|
+
};
|
|
67
|
+
});
|
|
64
68
|
files.push({ rel: SETTINGS, source: null });
|
|
69
|
+
files.push({ rel: CODEX_HOOKS, source: null });
|
|
65
70
|
return files;
|
|
66
71
|
}
|
|
67
72
|
/**
|
|
@@ -82,14 +87,23 @@ export async function initFileContents(repoDir, project) {
|
|
|
82
87
|
};
|
|
83
88
|
const files = await initManifest();
|
|
84
89
|
const contents = new Map();
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
const sourceFiles = files.filter((file) => file.source !== null);
|
|
91
|
+
const rendered = await mapConcurrent(sourceFiles, 16, async ({ rel, source }) => ({
|
|
92
|
+
rel,
|
|
93
|
+
content: substituteContent(await readFile(source, 'utf8'), ctx),
|
|
94
|
+
}));
|
|
95
|
+
for (const { rel, content } of rendered) {
|
|
96
|
+
contents.set(rel, content);
|
|
89
97
|
}
|
|
90
98
|
const installedHooks = new Set(files.map((f) => f.rel).filter((rel) => rel.startsWith('.claude/hooks/')));
|
|
91
|
-
const
|
|
99
|
+
const [shippedSettings, shippedCodexHooks] = await Promise.all([
|
|
100
|
+
readFile(path.join(agentOsUniversalDir(), SETTINGS), 'utf8'),
|
|
101
|
+
readFile(path.join(agentOsUniversalDir(), CODEX_HOOKS), 'utf8'),
|
|
102
|
+
]);
|
|
103
|
+
const shipped = JSON.parse(shippedSettings);
|
|
92
104
|
contents.set(SETTINGS, `${JSON.stringify(settingsForInstalledHooks(shipped, installedHooks), null, 2)}\n`);
|
|
105
|
+
const shippedCodex = JSON.parse(shippedCodexHooks);
|
|
106
|
+
contents.set(CODEX_HOOKS, `${JSON.stringify(settingsForInstalledHooks(shippedCodex, installedHooks), null, 2)}\n`);
|
|
93
107
|
return contents;
|
|
94
108
|
}
|
|
95
109
|
/** The process layer as a set of {@link InstalledFile}s — what `upgrade` reads. */
|
|
@@ -100,41 +114,49 @@ export async function initInstallSet(repoDir, project) {
|
|
|
100
114
|
}
|
|
101
115
|
export async function planInit(repoDir) {
|
|
102
116
|
const files = (await initManifest()).map((f) => f.rel);
|
|
103
|
-
const conflicts =
|
|
104
|
-
for (const rel of files) {
|
|
105
|
-
if (await exists(path.join(repoDir, rel)))
|
|
106
|
-
conflicts.push(rel);
|
|
107
|
-
}
|
|
117
|
+
const conflicts = (await mapConcurrent(files, 16, async (rel) => (await exists(path.join(repoDir, rel))) ? rel : null)).filter((rel) => rel !== null);
|
|
108
118
|
return { files: files.map((p) => ({ path: p })), conflicts };
|
|
109
119
|
}
|
|
110
120
|
export async function initProject(repoDir, options) {
|
|
121
|
+
// Refused before anything is read or written, so a deprecated flag cannot
|
|
122
|
+
// half-install: `upgrade` covers what this stood in for, and it decides per
|
|
123
|
+
// file from the manifest instead of overriding one refusal wholesale.
|
|
124
|
+
if (options.force)
|
|
125
|
+
throw new InitError(FORCE_DEPRECATED);
|
|
111
126
|
const files = (await initManifest()).map((f) => f.rel);
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
const previous = await readManifest(repoDir);
|
|
128
|
+
// Refuse to clobber an existing CLAUDE.md — init edits someone's working
|
|
129
|
+
// repository (brief §4, non-negotiable).
|
|
130
|
+
for (const map of MAPS) {
|
|
131
|
+
if (files.includes(map) && (await exists(path.join(repoDir, map)))) {
|
|
132
|
+
// A create rig whose CLAUDE.md was deleted is the legacy route into init.
|
|
133
|
+
// Its generated AGENTS.md must not newly close that route, but only the
|
|
134
|
+
// manifest can distinguish that file from a user's own Codex guidance.
|
|
135
|
+
if (map === 'AGENTS.md' &&
|
|
136
|
+
previous?.files[map] !== undefined &&
|
|
137
|
+
sha256(await readFile(path.join(repoDir, map), 'utf8')) === previous.files[map]) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
throw new InitError(`This repo already has an ${map}. Refusing to overwrite it. ` +
|
|
141
|
+
'Merge the agent-os map in by hand, or run create-agent-rig upgrade to refresh a rig.');
|
|
118
142
|
}
|
|
119
143
|
}
|
|
120
144
|
const contents = await initFileContents(repoDir);
|
|
121
|
-
const written = [];
|
|
122
|
-
const skipped = [];
|
|
123
145
|
const plannedCount = files.length;
|
|
124
|
-
|
|
146
|
+
const actions = await mapConcurrent(files, 16, async (rel) => {
|
|
125
147
|
const dest = path.join(repoDir, rel);
|
|
126
|
-
|
|
127
|
-
if ((await exists(dest)) && !(isForceableMeta && options.force)) {
|
|
148
|
+
if (await exists(dest)) {
|
|
128
149
|
// never overwrite a file init did not write (a user's own copy)
|
|
129
|
-
skipped
|
|
130
|
-
continue;
|
|
150
|
+
return { rel, verdict: 'skipped' };
|
|
131
151
|
}
|
|
132
152
|
if (options.dryRun)
|
|
133
|
-
|
|
153
|
+
return { rel, verdict: 'planned' };
|
|
134
154
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
135
155
|
await writeFile(dest, contents.get(rel) ?? '');
|
|
136
|
-
written
|
|
137
|
-
}
|
|
156
|
+
return { rel, verdict: 'written' };
|
|
157
|
+
});
|
|
158
|
+
const written = actions.filter(({ verdict }) => verdict === 'written').map(({ rel }) => rel);
|
|
159
|
+
const skipped = actions.filter(({ verdict }) => verdict === 'skipped').map(({ rel }) => rel);
|
|
138
160
|
if (!options.dryRun)
|
|
139
161
|
await recordInstall(repoDir, written, contents);
|
|
140
162
|
return { written, skipped, plannedCount };
|
|
@@ -147,6 +169,32 @@ export async function initProject(repoDir, options) {
|
|
|
147
169
|
* somebody else's — claiming it here would let the next upgrade replace a
|
|
148
170
|
* user's own document with the rig's. Earlier entries are preserved: a re-run
|
|
149
171
|
* writes nothing and must not therefore un-remember everything.
|
|
172
|
+
*
|
|
173
|
+
* 🔴 **`kind`, `project` and `stacks` are preserved, not rewritten.** Reached
|
|
174
|
+
* inside a rig `create` produced, this used to stamp `kind: 'init'`,
|
|
175
|
+
* `stacks: []` and an empty `region` over the truth — and `planUpgrade` trusts
|
|
176
|
+
* a manifest wholesale (it never re-detects), so the next upgrade routed to the
|
|
177
|
+
* `init` install set and the stack overlays left the plan entirely: not
|
|
178
|
+
* reported as deleted, not as a conflict, simply absent. `init` describes what
|
|
179
|
+
* it wrote; it does not get to re-describe how the rig was installed.
|
|
180
|
+
*
|
|
181
|
+
* ⚠ **The limit, stated because the fix reads as wider than it is:** this
|
|
182
|
+
* preserves a manifest, so a rig without a READABLE one still gets
|
|
183
|
+
* `kind: 'init'`, no stacks and an empty region, and the advisory in `runInit`
|
|
184
|
+
* stays silent for the same reason. That is three populations, not one: a rig
|
|
185
|
+
* from before 0.4.0 never had a manifest, a deleted manifest is a documented
|
|
186
|
+
* recovery step, and one on disk that `parseManifest` voids reads as absent to
|
|
187
|
+
* `readManifest` alike. `upgrade`'s `detectInstall`
|
|
188
|
+
* recovers all three from the files on disk, so those values are not
|
|
189
|
+
* unavailable, only unavailable *here*: reaching for it would point
|
|
190
|
+
* `commands/init` at `commands/upgrade`, which already imports this module.
|
|
191
|
+
* The fallback below is the honest floor, not the best available answer.
|
|
192
|
+
*
|
|
193
|
+
* The item that asked for this also floated refusing `init` outright on a
|
|
194
|
+
* `create` manifest. It is already refused a step earlier and for a different
|
|
195
|
+
* reason — {@link initProject} throws on the existing `CLAUDE.md`. The gap that
|
|
196
|
+
* leaves is a `create` rig whose `CLAUDE.md` was deleted, and this function is
|
|
197
|
+
* what makes that case safe.
|
|
150
198
|
*/
|
|
151
199
|
async function recordInstall(repoDir, written, contents) {
|
|
152
200
|
const previous = await readManifest(repoDir);
|
|
@@ -156,9 +204,12 @@ async function recordInstall(repoDir, written, contents) {
|
|
|
156
204
|
files[rel] = sha256(contents.get(rel) ?? '');
|
|
157
205
|
const manifest = {
|
|
158
206
|
version: await packageVersion(),
|
|
159
|
-
kind: 'init',
|
|
160
|
-
|
|
161
|
-
|
|
207
|
+
kind: previous?.kind ?? 'init',
|
|
208
|
+
// No manifest: fall back to the directory name, which is all this module
|
|
209
|
+
// reads. See the limit above — `upgrade` can do better from the files
|
|
210
|
+
// themselves, and `init` deliberately does not reach for it.
|
|
211
|
+
project: previous?.project ?? { name, scope: name, region: '' },
|
|
212
|
+
stacks: previous?.stacks ?? [],
|
|
162
213
|
files,
|
|
163
214
|
};
|
|
164
215
|
await writeManifest(repoDir, manifest);
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { initInstallSet, projectNameFor } from './init.js';
|
|
4
|
+
import { hookFilesReferencedIn } from '../lib/init-settings.js';
|
|
4
5
|
import { loadHashHistory, presentInEveryRelease } from '../lib/history.js';
|
|
5
6
|
import { agentOsInstallSet, agentOsLayerDirs } from '../lib/install-set.js';
|
|
6
7
|
import { listTree } from '../lib/copy-tree.js';
|
|
7
8
|
import { readManifest, sha256, writeManifest } from '../lib/manifest.js';
|
|
8
|
-
import { resolveInside } from '../lib/safe-path.js';
|
|
9
|
+
import { isSafeSubstitutionValue, resolveInside } from '../lib/safe-path.js';
|
|
9
10
|
import { detokenizeContent, substituteFileName } from '../lib/substitute.js';
|
|
10
11
|
import { TARGETS } from '../lib/targets.js';
|
|
11
12
|
import { packageVersion } from '../lib/version.js';
|
|
@@ -13,6 +14,8 @@ import { packageVersion } from '../lib/version.js';
|
|
|
13
14
|
export class UpgradeError extends Error {
|
|
14
15
|
}
|
|
15
16
|
const SETTINGS = '.claude/settings.json';
|
|
17
|
+
const CODEX_HOOKS = '.codex/hooks.json';
|
|
18
|
+
const WIRING_PATHS = new Set([SETTINGS, CODEX_HOOKS]);
|
|
16
19
|
/** The universal layer's architecture group — installed by `create`, never by `init`. */
|
|
17
20
|
const ARCHITECTURE_ONLY = [
|
|
18
21
|
'.claude/rules/architecture.md',
|
|
@@ -130,6 +133,30 @@ function isReleasedVersion(history, rel, content, ctx) {
|
|
|
130
133
|
const candidates = new Set([sha256(content), sha256(detokenizeContent(content, ctx))]);
|
|
131
134
|
return known.hashes.some((hash) => candidates.has(hash));
|
|
132
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Would writing `next` over `current` stop calling a hook that is still there?
|
|
138
|
+
*
|
|
139
|
+
* The one question the hash arms cannot answer. They prove the bytes belong to
|
|
140
|
+
* the rig; they do not prove the replacement wires the same hooks, and
|
|
141
|
+
* Wiring files have flavours that differ in exactly that. A hook file
|
|
142
|
+
* still on disk with nothing wired to it is the quiet failure
|
|
143
|
+
* `lib/init-settings.ts` names: the rules claim it is enforced and nothing ever
|
|
144
|
+
* calls it.
|
|
145
|
+
*
|
|
146
|
+
* Only hooks whose FILE is still present count. One the user deleted on purpose
|
|
147
|
+
* is not being silenced by this write — it was already gone.
|
|
148
|
+
*/
|
|
149
|
+
async function unwiresAnInstalledHook(repoDir, current, next) {
|
|
150
|
+
const nextHooks = hookFilesReferencedIn(next);
|
|
151
|
+
for (const hook of hookFilesReferencedIn(current)) {
|
|
152
|
+
if (nextHooks.has(hook))
|
|
153
|
+
continue;
|
|
154
|
+
const onDisk = resolveInside(repoDir, hook);
|
|
155
|
+
if (onDisk !== null && (await exists(onDisk)))
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
133
160
|
/**
|
|
134
161
|
* What an upgrade would do, decided per file, writing nothing.
|
|
135
162
|
*
|
|
@@ -146,11 +173,37 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
146
173
|
? await detectInstall(repoDir)
|
|
147
174
|
: { kind: manifest.kind, stacks: manifest.stacks, region: manifest.project.region };
|
|
148
175
|
const kind = manifest?.kind ?? detected.kind;
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
176
|
+
// With no manifest to read, guess the name the rig's own files were written
|
|
177
|
+
// with — and each command wrote them differently, so the guess branches the
|
|
178
|
+
// same way:
|
|
179
|
+
//
|
|
180
|
+
// - `init` substitutes the **slug** and records the slug (`init.ts`,
|
|
181
|
+
// `projectNameFor` in both places), so for an init rig the slug is not an
|
|
182
|
+
// approximation, it is the value;
|
|
183
|
+
// - `create` substitutes the name it was **given**, having validated it — and
|
|
184
|
+
// that validation accepts a trailing `-` or `.`, which `projectNameFor`
|
|
185
|
+
// strips. So slugging a create rig renames it: `my-app.` became `my-app`,
|
|
186
|
+
// stopped matching its own installed files, and returned four of them as
|
|
187
|
+
// conflicts.
|
|
188
|
+
//
|
|
189
|
+
// The one case where the raw name cannot be kept is a directory the manifest
|
|
190
|
+
// reader would refuse — `My App` produced `{"name":"My App"}`, which
|
|
191
|
+
// `parseManifest` voids, so the manifest this command exists to write was
|
|
192
|
+
// written and immediately unreadable, and every later run fell back to
|
|
193
|
+
// matching against released versions. The condition is that reader's own
|
|
194
|
+
// exported predicate, not a second copy of its rule.
|
|
195
|
+
//
|
|
196
|
+
// 🔴 All three branches were bought by a defect, and two of those defects
|
|
197
|
+
// were introduced by fixing the other — the mirror is easy to miss, because
|
|
198
|
+
// each fix looks total until the other kind is tried. Change nothing here
|
|
199
|
+
// without running the three sibling cases in `upgrade.test.ts`.
|
|
200
|
+
//
|
|
201
|
+
// ⚠ It is still the *directory's* name, so a renamed or cloned rig with no
|
|
202
|
+
// manifest bootstraps the new name and its substituted files come back as
|
|
203
|
+
// conflicts — kept and reported, never overwritten. Committing the manifest
|
|
204
|
+
// is what removes the guess, and that is unchanged from 0.4.0.
|
|
205
|
+
const rawName = path.basename(path.resolve(repoDir));
|
|
206
|
+
const bootstrapName = kind === 'init' || !isSafeSubstitutionValue(rawName) ? projectNameFor(repoDir) : rawName;
|
|
154
207
|
const project = manifest?.project ?? {
|
|
155
208
|
name: bootstrapName,
|
|
156
209
|
scope: bootstrapName,
|
|
@@ -172,6 +225,7 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
172
225
|
const contents = new Map();
|
|
173
226
|
const nextFiles = {};
|
|
174
227
|
let wiring = null;
|
|
228
|
+
const wiringByPath = new Map();
|
|
175
229
|
for (const file of files) {
|
|
176
230
|
const current = await readIfPresent(repoDir, file.rel);
|
|
177
231
|
const recorded = manifest?.files[file.rel];
|
|
@@ -202,36 +256,63 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
202
256
|
}
|
|
203
257
|
continue;
|
|
204
258
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
259
|
+
// The two limits that keep wiring files' new replaceability from
|
|
260
|
+
// disarming the rig, both measured rather than reasoned about.
|
|
261
|
+
//
|
|
262
|
+
// 1. The released-hash fallback is not enough for THIS file. Every other
|
|
263
|
+
// file has one flavour; this one has two — `create` wires all the hooks,
|
|
264
|
+
// `init` wires only the ones it installs — and they share a history
|
|
265
|
+
// entry. A manifest-less rig that ran `init` is recorded `kind: 'init'`,
|
|
266
|
+
// so matching a released hash would write the narrow wiring over the
|
|
267
|
+
// full one. The item asks for the manifest arm and says the rest is
|
|
268
|
+
// reported, which is also the reading with no regression behind it.
|
|
269
|
+
// 2. Even the manifest arm is not enough on its own, because `kind` is
|
|
270
|
+
// trusted from a file that travels in pull requests. So the decision is
|
|
271
|
+
// gated on the wiring itself: if the replacement would stop calling a
|
|
272
|
+
// hook still sitting in `.claude/hooks/`, it is handed over. That check
|
|
273
|
+
// does not care which flavour anything claims to be.
|
|
274
|
+
const isWiring = WIRING_PATHS.has(file.rel);
|
|
275
|
+
const wouldUnwireAnInstalledHook = isWiring && (await unwiresAnInstalledHook(repoDir, current, file.content));
|
|
276
|
+
const vouched = isWiring
|
|
277
|
+
? recorded !== undefined && sha256(current) === recorded
|
|
278
|
+
: (recorded !== undefined && sha256(current) === recorded) ||
|
|
279
|
+
isReleasedVersion(history, file.rel, current, ctx);
|
|
224
280
|
if (current === file.content) {
|
|
225
281
|
actions.push({ rel: file.rel, verdict: 'unchanged' });
|
|
226
282
|
nextFiles[file.rel] = sha256(file.content);
|
|
227
283
|
}
|
|
228
|
-
else if (
|
|
229
|
-
actions.push({
|
|
284
|
+
else if (vouched && !wouldUnwireAnInstalledHook) {
|
|
285
|
+
actions.push({
|
|
286
|
+
rel: file.rel,
|
|
287
|
+
verdict: 'update',
|
|
288
|
+
templatePath: file.source,
|
|
289
|
+
// Every other replacement is routine; this one rewrites what calls the
|
|
290
|
+
// guards, so it says so rather than arriving as one more `~` line.
|
|
291
|
+
...(isWiring ? { reason: 'the hook wiring, replaced — you never edited it' } : {}),
|
|
292
|
+
});
|
|
230
293
|
nextFiles[file.rel] = sha256(file.content);
|
|
231
294
|
}
|
|
232
|
-
else if (
|
|
233
|
-
|
|
234
|
-
|
|
295
|
+
else if (isWiring) {
|
|
296
|
+
// Nothing vouches for these bytes, or replacing them would silence a hook
|
|
297
|
+
// that is still installed. Either way this is the one file whose conflict
|
|
298
|
+
// is a merge rather than a choice, so the released file is handed over.
|
|
299
|
+
wiring = file.content;
|
|
300
|
+
wiringByPath.set(file.rel, file.content);
|
|
301
|
+
actions.push({
|
|
302
|
+
rel: file.rel,
|
|
303
|
+
verdict: 'wiring',
|
|
304
|
+
// Say what was actually checked. This file no longer consults the
|
|
305
|
+
// released-hash table (see the limits above), so it cannot claim these
|
|
306
|
+
// bytes are not a release — only that the manifest does not vouch for
|
|
307
|
+
// them, which is the check that ran.
|
|
308
|
+
reason: wouldUnwireAnInstalledHook
|
|
309
|
+
? 'replacing it would stop calling a hook that is still installed — merge the entries below by hand'
|
|
310
|
+
: recorded === undefined
|
|
311
|
+
? 'the manifest does not vouch for it — treated as yours, merge the entries below by hand'
|
|
312
|
+
: 'edited since it was installed — merge the entries below by hand',
|
|
313
|
+
});
|
|
314
|
+
if (recorded !== undefined)
|
|
315
|
+
nextFiles[file.rel] = recorded;
|
|
235
316
|
}
|
|
236
317
|
else {
|
|
237
318
|
actions.push({
|
|
@@ -263,6 +344,7 @@ export async function planUpgrade(repoDir, options = {}) {
|
|
|
263
344
|
bootstrapped: manifest === null,
|
|
264
345
|
actions,
|
|
265
346
|
wiring,
|
|
347
|
+
wiringByPath,
|
|
266
348
|
contents,
|
|
267
349
|
manifest: {
|
|
268
350
|
version: await packageVersion(),
|
|
@@ -4,13 +4,14 @@ import { CreateError, createProject } from './commands/create.js';
|
|
|
4
4
|
import { InitError, initFileContents, initProject, planInit } from './commands/init.js';
|
|
5
5
|
import { UpgradeError, applyUpgrade, planUpgrade } from './commands/upgrade.js';
|
|
6
6
|
import { makePalette } from './lib/colors.js';
|
|
7
|
+
import { readManifest } from './lib/manifest.js';
|
|
7
8
|
import { promptConfirm, promptTarget } from './lib/prompts.js';
|
|
8
9
|
import { collectGovernance, renderSummary } from './lib/summary.js';
|
|
9
10
|
import { DEFAULT_TARGET, TARGET_NAMES } from './lib/targets.js';
|
|
10
11
|
import { packageVersion } from './lib/version.js';
|
|
11
12
|
const USAGE = `Usage: create-agent-rig <dir> [options]
|
|
12
13
|
|
|
13
|
-
Scaffolds a new project into <dir>: agent operating system
|
|
14
|
+
Scaffolds a new project into <dir>: a Claude Code + Codex agent operating system
|
|
14
15
|
plus a runnable code skeleton. Refuses to write into a non-empty directory.
|
|
15
16
|
|
|
16
17
|
Options
|
|
@@ -22,9 +23,12 @@ Options
|
|
|
22
23
|
--version print the version
|
|
23
24
|
-h, --help this text
|
|
24
25
|
|
|
25
|
-
Also: create-agent-rig init [--dry-run]
|
|
26
|
+
Also: create-agent-rig init [--dry-run]
|
|
26
27
|
Install the process layer (rules, gates, stop rules — no architecture
|
|
27
|
-
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
|
|
28
|
+
assumptions) into the CURRENT existing repo. Refuses to clobber CLAUDE.md
|
|
29
|
+
or AGENTS.md.
|
|
30
|
+
--force is deprecated: it refuses and points at upgrade, which refreshes a
|
|
31
|
+
rig file by file. It is removed in 0.6.
|
|
28
32
|
|
|
29
33
|
Also: create-agent-rig upgrade [--dry-run] [--yes]
|
|
30
34
|
Bring the rig in the CURRENT repo up to this version. Replaces the files it
|
|
@@ -34,7 +38,13 @@ async function runInit(rawArgs) {
|
|
|
34
38
|
try {
|
|
35
39
|
({ values } = parseArgs({
|
|
36
40
|
args: rawArgs,
|
|
37
|
-
|
|
41
|
+
// `--no-color` for the same reason it is accepted on `upgrade`: USAGE
|
|
42
|
+
// offers it without scoping it to one command.
|
|
43
|
+
options: {
|
|
44
|
+
'dry-run': { type: 'boolean' },
|
|
45
|
+
force: { type: 'boolean' },
|
|
46
|
+
'no-color': { type: 'boolean' },
|
|
47
|
+
},
|
|
38
48
|
allowPositionals: false,
|
|
39
49
|
}));
|
|
40
50
|
}
|
|
@@ -44,10 +54,25 @@ async function runInit(rawArgs) {
|
|
|
44
54
|
}
|
|
45
55
|
const cwd = process.cwd();
|
|
46
56
|
const dryRun = values['dry-run'] === true;
|
|
57
|
+
// `init` adopts a repo the rig knows nothing about. Run inside a rig `create`
|
|
58
|
+
// generated — reachable when its CLAUDE.md was deleted — it is the wrong
|
|
59
|
+
// command: it installs the process layer alone and never refreshes the stack
|
|
60
|
+
// overlays. Say so before anything is written, so it is visible on --dry-run
|
|
61
|
+
// too.
|
|
62
|
+
//
|
|
63
|
+
// It is a manifest read, so any rig without a READABLE manifest gets no
|
|
64
|
+
// advisory even when it came from `create` — one never written, one deleted,
|
|
65
|
+
// and one present but voided by `parseManifest` all reach here alike. The
|
|
66
|
+
// same limit `recordInstall` carries, stated in both places because either
|
|
67
|
+
// one alone reads as wider.
|
|
68
|
+
const existing = await readManifest(cwd);
|
|
47
69
|
const plan = await planInit(cwd);
|
|
48
70
|
process.stdout.write(`agent-rig init — process layer into ${cwd}\n\n` +
|
|
49
71
|
plan.files.map((f) => ` + ${f.path}`).join('\n') +
|
|
50
72
|
'\n');
|
|
73
|
+
if (existing?.kind === 'create') {
|
|
74
|
+
process.stdout.write(`\n! This rig was created by create-agent-rig — \`init\` only fills gaps here; use \`upgrade\` to refresh.\n`);
|
|
75
|
+
}
|
|
51
76
|
if (plan.conflicts.length > 0) {
|
|
52
77
|
process.stdout.write(`\nAlready present (kept, not overwritten):\n` +
|
|
53
78
|
plan.conflicts.map((c) => ` · ${c}`).join('\n') +
|
|
@@ -61,12 +86,15 @@ async function runInit(rawArgs) {
|
|
|
61
86
|
process.stdout.write(`\nInstalled ${result.written.length} files` +
|
|
62
87
|
(result.skipped.length ? `, kept ${result.skipped.length} existing` : '') +
|
|
63
88
|
'.\n');
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
// A kept harness config silently disables that harness's enforcement: the
|
|
90
|
+
// hooks sit on disk and are never called, while the rules claim they are.
|
|
91
|
+
// Say so loudly, and hand over the exact entries for each affected harness.
|
|
92
|
+
const generated = await initFileContents(cwd);
|
|
93
|
+
for (const wiringPath of ['.claude/settings.json', '.codex/hooks.json']) {
|
|
94
|
+
if (!result.skipped.includes(wiringPath))
|
|
95
|
+
continue;
|
|
96
|
+
const wiring = generated.get(wiringPath) ?? '';
|
|
97
|
+
process.stdout.write(`\n! ${wiringPath} already exists — it was kept, so the rig's hooks are NOT wired there.\n` +
|
|
70
98
|
` Until you merge these entries into it, nothing enforces the rules:\n\n` +
|
|
71
99
|
wiring.replace(/^/gm, ' ') +
|
|
72
100
|
'\n');
|
|
@@ -86,7 +114,7 @@ function renderUpgradePlan(repoDir, plan) {
|
|
|
86
114
|
const lines = [
|
|
87
115
|
`agent-rig upgrade — ${plan.kind} rig in ${repoDir}`,
|
|
88
116
|
plan.bootstrapped
|
|
89
|
-
? ` no manifest here (
|
|
117
|
+
? ` no readable manifest here (deleted, never written, or unparseable) — matching files against released versions`
|
|
90
118
|
: ` installed by ${plan.fromVersion}`,
|
|
91
119
|
` upgrading to ${plan.toVersion}`,
|
|
92
120
|
'',
|
|
@@ -100,9 +128,29 @@ function renderUpgradePlan(repoDir, plan) {
|
|
|
100
128
|
}
|
|
101
129
|
}
|
|
102
130
|
}
|
|
103
|
-
|
|
131
|
+
// Every one of `UpgradeVerdict`'s six members is accounted for here.
|
|
132
|
+
// `wiring` and `deleted` each print their own line and were in none of the
|
|
133
|
+
// buckets, so a reader counted lines and was told a smaller number.
|
|
134
|
+
// (`unchanged` is counted and prints nothing — the sum is over actions, not
|
|
135
|
+
// over printed lines.) The two appear only when they occurred, so a plan
|
|
136
|
+
// without them renders exactly as it always has. Pinned by, in cli-report.test.ts,
|
|
137
|
+
// "renders a plan with no wiring action exactly as it does today".
|
|
138
|
+
// `deleted` before `wiring`, the relative order the plan prints them in.
|
|
139
|
+
// ⚠ Only their order relative to EACH OTHER matches: the plan prints
|
|
140
|
+
// `deleted` before `conflict` and the summary prints it after, so this is not
|
|
141
|
+
// a plan-ordered line. Pinned by, in cli-report.test.ts,
|
|
142
|
+
// "lists the two occasional buckets in the order the plan prints them".
|
|
143
|
+
const occasional = [
|
|
144
|
+
['deleted', (n) => `${n} you removed (left removed)`],
|
|
145
|
+
['wiring', (n) => `${n} wiring handed over`],
|
|
146
|
+
];
|
|
147
|
+
const extra = occasional
|
|
148
|
+
.map(([verdict, phrase]) => [of(verdict).length, phrase])
|
|
149
|
+
.filter(([count]) => count > 0)
|
|
150
|
+
.map(([count, phrase]) => phrase(count));
|
|
104
151
|
lines.push('', ` ${of('update').length} to replace, ${of('new').length} new, ` +
|
|
105
|
-
`${of('conflict').length} yours (kept),
|
|
152
|
+
`${of('conflict').length} yours (kept), ` +
|
|
153
|
+
[...extra, `${of('unchanged').length} already current`].join(', '));
|
|
106
154
|
return `${lines.join('\n')}\n`;
|
|
107
155
|
}
|
|
108
156
|
async function runUpgrade(rawArgs) {
|
|
@@ -110,7 +158,17 @@ async function runUpgrade(rawArgs) {
|
|
|
110
158
|
try {
|
|
111
159
|
({ values } = parseArgs({
|
|
112
160
|
args: rawArgs,
|
|
113
|
-
|
|
161
|
+
// `--no-color` is advertised in USAGE without scoping it to one command, so
|
|
162
|
+
// every command accepts it. Refusing a flag the help offers costs the
|
|
163
|
+
// reader more than honouring it costs us — and honouring it is only a
|
|
164
|
+
// parse here, because the sole palette lives on the `create` path below.
|
|
165
|
+
// Pinned by, in cli-report.test.ts,
|
|
166
|
+
// "upgrade accepts --no-color and prints plain output".
|
|
167
|
+
options: {
|
|
168
|
+
'dry-run': { type: 'boolean' },
|
|
169
|
+
yes: { type: 'boolean' },
|
|
170
|
+
'no-color': { type: 'boolean' },
|
|
171
|
+
},
|
|
114
172
|
allowPositionals: false,
|
|
115
173
|
}));
|
|
116
174
|
}
|
|
@@ -124,11 +182,15 @@ async function runUpgrade(rawArgs) {
|
|
|
124
182
|
// The one thing this command will not do for you — printed with the plan,
|
|
125
183
|
// because the dry run is where a reader decides whether there is work here,
|
|
126
184
|
// and a report that mentions entries it never shows is not a plan.
|
|
127
|
-
if (plan.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
185
|
+
if (plan.wiringByPath.size > 0) {
|
|
186
|
+
for (const [wiringPath, wiring] of plan.wiringByPath) {
|
|
187
|
+
process.stdout.write(`\n! ${wiringPath} was handed over rather than replaced — the reason is\n` +
|
|
188
|
+
` on its line above. It is hook wiring, so it is never overwritten\n` +
|
|
189
|
+
` without proof the rig wrote those exact bytes.\n` +
|
|
190
|
+
` This version wires it like this; merge in what is missing:\n\n` +
|
|
191
|
+
wiring.replace(/^/gm, ' ') +
|
|
192
|
+
'\n');
|
|
193
|
+
}
|
|
132
194
|
}
|
|
133
195
|
if (values['dry-run'] === true) {
|
|
134
196
|
process.stdout.write('\nDry run — nothing written.\n');
|
|
@@ -25,8 +25,38 @@ function isBinary(buffer) {
|
|
|
25
25
|
}
|
|
26
26
|
export async function copyTree(srcDir, destDir, options = {}) {
|
|
27
27
|
const ignore = new Set(options.ignore ?? DEFAULT_IGNORE);
|
|
28
|
+
const files = [];
|
|
28
29
|
await mkdir(destDir, { recursive: true });
|
|
29
|
-
await
|
|
30
|
+
await collectCopyTasks(srcDir, destDir, '', { ...options, ignore }, files);
|
|
31
|
+
await mapConcurrent(files, 16, ({ srcPath, destPath, relPath }) => copyFileEntry(srcPath, destPath, relPath, { ...options, ignore }));
|
|
32
|
+
}
|
|
33
|
+
/** Run independent async work through a bounded pool while preserving result order. */
|
|
34
|
+
export async function mapConcurrent(items, limit, worker) {
|
|
35
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
36
|
+
throw new RangeError('concurrency limit must be positive');
|
|
37
|
+
const results = new Array(items.length);
|
|
38
|
+
let next = 0;
|
|
39
|
+
let failed = false;
|
|
40
|
+
let firstError;
|
|
41
|
+
const run = async () => {
|
|
42
|
+
while (!failed && next < items.length) {
|
|
43
|
+
const index = next;
|
|
44
|
+
next += 1;
|
|
45
|
+
try {
|
|
46
|
+
results[index] = await worker(items[index]);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (!failed) {
|
|
50
|
+
failed = true;
|
|
51
|
+
firstError = error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
|
|
57
|
+
if (failed)
|
|
58
|
+
throw firstError;
|
|
59
|
+
return results;
|
|
30
60
|
}
|
|
31
61
|
/**
|
|
32
62
|
* The destination-relative file paths {@link copyTree} would produce — same
|
|
@@ -62,7 +92,7 @@ export async function listTreeEntries(srcDir, options = {}) {
|
|
|
62
92
|
await walk(srcDir, '');
|
|
63
93
|
return files;
|
|
64
94
|
}
|
|
65
|
-
async function
|
|
95
|
+
async function collectCopyTasks(srcDir, destDir, relDir, options, files) {
|
|
66
96
|
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
67
97
|
for (const entry of entries) {
|
|
68
98
|
if (options.ignore.has(entry.name))
|
|
@@ -73,10 +103,10 @@ async function copyDir(srcDir, destDir, relDir, options) {
|
|
|
73
103
|
const relPath = path.join(relDir, entry.name);
|
|
74
104
|
if (entry.isDirectory()) {
|
|
75
105
|
await mkdir(destPath, { recursive: true });
|
|
76
|
-
await
|
|
106
|
+
await collectCopyTasks(srcPath, destPath, relPath, options, files);
|
|
77
107
|
}
|
|
78
108
|
else if (entry.isFile()) {
|
|
79
|
-
|
|
109
|
+
files.push({ srcPath, destPath, relPath });
|
|
80
110
|
}
|
|
81
111
|
// Symlinks and other special entries are intentionally not copied:
|
|
82
112
|
// templates are plain trees.
|
|
@@ -87,7 +117,7 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
|
|
|
87
117
|
await copyFile(srcPath, destPath); // copyFile preserves the mode by itself
|
|
88
118
|
return;
|
|
89
119
|
}
|
|
90
|
-
const buffer = await readFile(srcPath);
|
|
120
|
+
const [buffer, { mode }] = await Promise.all([readFile(srcPath), stat(srcPath)]);
|
|
91
121
|
if (isBinary(buffer)) {
|
|
92
122
|
await writeFile(destPath, buffer);
|
|
93
123
|
}
|
|
@@ -96,6 +126,5 @@ async function copyFileEntry(srcPath, destPath, relPath, options) {
|
|
|
96
126
|
}
|
|
97
127
|
// writeFile does NOT preserve permissions — restore them (chmod ignores umask),
|
|
98
128
|
// otherwise executable template files (scripts, hooks) arrive non-executable.
|
|
99
|
-
const { mode } = await stat(srcPath);
|
|
100
129
|
await chmod(destPath, mode & 0o777);
|
|
101
130
|
}
|