@ghl-ai/aw 0.1.44-beta.10 → 0.1.44-beta.11
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/c4/initRepo.mjs +244 -19
- package/c4/templates/codex/config.toml +5 -0
- package/c4/templates/gitignore-block.txt +14 -0
- package/c4/templates/manifest.json +12 -0
- package/commands/init-repo.mjs +18 -7
- package/package.json +1 -1
package/c4/initRepo.mjs
CHANGED
|
@@ -23,9 +23,127 @@ import {
|
|
|
23
23
|
chmodSync,
|
|
24
24
|
statSync,
|
|
25
25
|
} from 'node:fs';
|
|
26
|
-
import { join, dirname } from 'node:path';
|
|
26
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
import { createHash } from 'node:crypto';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default `git check-ignore` runner. Returns true when `relpath` is ignored
|
|
33
|
+
* by the user's gitignore rules, false otherwise (or when git is unavailable).
|
|
34
|
+
*
|
|
35
|
+
* Exit codes per `git check-ignore --quiet`:
|
|
36
|
+
* 0 — file IS ignored
|
|
37
|
+
* 1 — file is NOT ignored
|
|
38
|
+
* 128 — error (no git repo, etc.) — treat as not-ignored to avoid noisy
|
|
39
|
+
* false-positive warnings on broken installs
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_CHECK_IGNORE = (repoRoot, relpath) => {
|
|
42
|
+
try {
|
|
43
|
+
const result = spawnSync(
|
|
44
|
+
'git',
|
|
45
|
+
['-C', repoRoot, 'check-ignore', '--quiet', relpath],
|
|
46
|
+
{ stdio: 'pipe' },
|
|
47
|
+
);
|
|
48
|
+
return result.status === 0;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Variable substitution table for `kind: "interpolated"` templates.
|
|
56
|
+
*
|
|
57
|
+
* Each value is computed lazily from `opts` (the engine call site provides
|
|
58
|
+
* `repoRoot`). Adding a new variable here means it becomes referenceable as
|
|
59
|
+
* `{{NAME}}` inside any interpolated template.
|
|
60
|
+
*
|
|
61
|
+
* REPO_BASENAME is the leaf directory name of `repoRoot` after `path.resolve`
|
|
62
|
+
* — exactly what Codex Cloud uses as the project key (`/workspace/<basename>`).
|
|
63
|
+
*/
|
|
64
|
+
const SUBSTITUTIONS = {
|
|
65
|
+
REPO_BASENAME: (opts) => basename(resolve(opts.repoRoot)),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render `{{VAR}}` placeholders in `content` using the SUBSTITUTIONS table.
|
|
70
|
+
*
|
|
71
|
+
* Throws on any `{{...}}` reference whose key isn't in the table — fail loud
|
|
72
|
+
* over silently shipping `{{REPO_BASENAME}}` in a config file.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} content
|
|
75
|
+
* @param {{repoRoot: string}} opts
|
|
76
|
+
* @returns {string} content with all known placeholders substituted
|
|
77
|
+
*/
|
|
78
|
+
function renderInterpolated(content, opts) {
|
|
79
|
+
return content.replace(/\{\{(\w+)\}\}/g, (_, name) => {
|
|
80
|
+
if (!Object.prototype.hasOwnProperty.call(SUBSTITUTIONS, name)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`init-repo: unknown variable {{${name}}} in interpolated template. ` +
|
|
83
|
+
`Known: ${Object.keys(SUBSTITUTIONS).join(', ')}.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return SUBSTITUTIONS[name](opts);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Render the full expected file content for a `kind: "managed-block"` template.
|
|
92
|
+
*
|
|
93
|
+
* Reads the bundled block source (just the inner lines), strips any existing
|
|
94
|
+
* managed block bearing the same `marker` from the dest file (so re-runs
|
|
95
|
+
* cleanly replace stale content), and appends a fresh marker-wrapped block.
|
|
96
|
+
*
|
|
97
|
+
* Format on disk (matches existing AW convention from integrate.mjs and
|
|
98
|
+
* repoRootInstructions.mjs — colon-no-space, marker name on both ends):
|
|
99
|
+
* <pre-existing user content (managed block stripped)>
|
|
100
|
+
*
|
|
101
|
+
* # aw-managed:start <marker> (DO NOT EDIT — managed by `aw init-repo`)
|
|
102
|
+
* <block source content>
|
|
103
|
+
* # aw-managed:end <marker>
|
|
104
|
+
*
|
|
105
|
+
* The single blank line separator between user content and our block is only
|
|
106
|
+
* emitted when prior content exists. The file always ends with one trailing
|
|
107
|
+
* newline.
|
|
108
|
+
*
|
|
109
|
+
* @param {{src: string, marker: string}} template
|
|
110
|
+
* @param {{templatesDir: string, repoRoot: string}} opts
|
|
111
|
+
* @returns {string} full expected file content
|
|
112
|
+
*/
|
|
113
|
+
function renderManagedBlock(template, opts) {
|
|
114
|
+
const blockSrcPath = join(opts.templatesDir, template.src);
|
|
115
|
+
const blockBody = readFileSync(blockSrcPath, 'utf8').replace(/\n+$/, '');
|
|
116
|
+
|
|
117
|
+
const destPath = join(opts.repoRoot, template.dest);
|
|
118
|
+
const existing = existsSync(destPath) ? readFileSync(destPath, 'utf8') : '';
|
|
119
|
+
const preserved = stripManagedBlock(existing, template.marker).replace(/\n+$/, '');
|
|
120
|
+
|
|
121
|
+
const wrapped =
|
|
122
|
+
`# aw-managed:start ${template.marker} (DO NOT EDIT — managed by \`aw init-repo\`)\n` +
|
|
123
|
+
blockBody +
|
|
124
|
+
'\n' +
|
|
125
|
+
`# aw-managed:end ${template.marker}\n`;
|
|
126
|
+
|
|
127
|
+
return preserved.length > 0 ? `${preserved}\n\n${wrapped}` : wrapped;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Remove every `# aw-managed:start <marker> ... # aw-managed:end <marker>`
|
|
132
|
+
* block from `content`. Idempotent on content with no such block.
|
|
133
|
+
*
|
|
134
|
+
* Defensive against multiple stale blocks with the same marker (all collapse
|
|
135
|
+
* to none — fresh block is appended afterward by the caller).
|
|
136
|
+
*/
|
|
137
|
+
function stripManagedBlock(content, marker) {
|
|
138
|
+
const safeMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
139
|
+
const re = new RegExp(
|
|
140
|
+
`(?:^|\\n)# aw-managed:start ${safeMarker}(?:[^\\n]*)\\n` +
|
|
141
|
+
`[\\s\\S]*?` +
|
|
142
|
+
`# aw-managed:end ${safeMarker}[ \\t]*\\n?`,
|
|
143
|
+
'g',
|
|
144
|
+
);
|
|
145
|
+
return content.replace(re, '\n');
|
|
146
|
+
}
|
|
29
147
|
|
|
30
148
|
/** Default fs surface — node:fs. Tests inject mocks for failure paths. */
|
|
31
149
|
const DEFAULT_FS = {
|
|
@@ -88,19 +206,40 @@ export function loadManifest(templatesDir) {
|
|
|
88
206
|
}
|
|
89
207
|
|
|
90
208
|
/**
|
|
91
|
-
*
|
|
209
|
+
* Render the expected file content for a single manifest entry.
|
|
210
|
+
*
|
|
211
|
+
* Dispatch by `template.kind`:
|
|
212
|
+
* - 'literal' → readFileSync of the bundled src verbatim
|
|
213
|
+
* - 'interpolated' → readFileSync + {{VAR}} substitution via SUBSTITUTIONS
|
|
214
|
+
* - 'managed-block' → strip stale managed block from dest, append fresh
|
|
215
|
+
* marker-wrapped block built from src body. Returns the
|
|
216
|
+
* FULL rendered dest content (preserves user content
|
|
217
|
+
* outside the markers).
|
|
92
218
|
*
|
|
93
|
-
* Pure read — no FS writes.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
219
|
+
* Pure read — no FS writes. Tests can exercise this directly. The action
|
|
220
|
+
* object carries both expected and current content so applyActions and the
|
|
221
|
+
* diff renderer don't have to re-read.
|
|
96
222
|
*
|
|
97
|
-
* @param {{src: string}} template
|
|
98
|
-
* @param {{templatesDir: string}} opts
|
|
99
|
-
* @returns {string}
|
|
223
|
+
* @param {{kind?: string, src: string, dest?: string, marker?: string}} template
|
|
224
|
+
* @param {{templatesDir: string, repoRoot?: string}} opts
|
|
225
|
+
* @returns {string} rendered template content (UTF-8)
|
|
100
226
|
*/
|
|
101
227
|
export function expectedContent(template, opts) {
|
|
102
|
-
const
|
|
103
|
-
|
|
228
|
+
const kind = template.kind ?? 'literal';
|
|
229
|
+
|
|
230
|
+
if (kind === 'literal') {
|
|
231
|
+
return readFileSync(join(opts.templatesDir, template.src), 'utf8');
|
|
232
|
+
}
|
|
233
|
+
if (kind === 'interpolated') {
|
|
234
|
+
const raw = readFileSync(join(opts.templatesDir, template.src), 'utf8');
|
|
235
|
+
return renderInterpolated(raw, opts);
|
|
236
|
+
}
|
|
237
|
+
if (kind === 'managed-block') {
|
|
238
|
+
return renderManagedBlock(template, opts);
|
|
239
|
+
}
|
|
240
|
+
throw new Error(
|
|
241
|
+
`init-repo: unknown template kind "${kind}" for ${template.src ?? '?'}.`,
|
|
242
|
+
);
|
|
104
243
|
}
|
|
105
244
|
|
|
106
245
|
/**
|
|
@@ -159,7 +298,12 @@ export function planActions(opts, manifest) {
|
|
|
159
298
|
}
|
|
160
299
|
|
|
161
300
|
let action;
|
|
162
|
-
if (
|
|
301
|
+
if (template.kind === 'managed-block') {
|
|
302
|
+
// We own the marker-bracketed area; user edits OUTSIDE the markers are
|
|
303
|
+
// preserved by renderManagedBlock. Mismatch always means "our block is
|
|
304
|
+
// stale or missing" → safe to overwrite.
|
|
305
|
+
action = 'overwrite';
|
|
306
|
+
} else if (opts.diff) {
|
|
163
307
|
action = 'diff';
|
|
164
308
|
} else if (opts.force) {
|
|
165
309
|
action = 'overwrite';
|
|
@@ -206,17 +350,75 @@ export function applyActions(opts, actions) {
|
|
|
206
350
|
fs.mkdirSync(dirname(action.dest), { recursive: true });
|
|
207
351
|
fs.writeFileSync(action.dest, action.expectedContent);
|
|
208
352
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
353
|
+
if (action.template.mode) {
|
|
354
|
+
try {
|
|
355
|
+
fs.chmodSync(action.dest, parseInt(action.template.mode, 8));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
writer.stderr(
|
|
358
|
+
`init-repo: WARN: failed to chmod ${action.dest}: ${err.code ?? err.message}. ` +
|
|
359
|
+
`File written but executable bit may be missing — set manually with chmod +x ${action.dest}.\n`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
216
362
|
}
|
|
217
363
|
}
|
|
218
364
|
}
|
|
219
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Run `git check-ignore` against every scaffolded file and return the list
|
|
368
|
+
* of paths still ignored after applyActions wrote the managed `.gitignore`
|
|
369
|
+
* block. Surfaces pathological gitignore patterns we can't override (most
|
|
370
|
+
* notably dir-only rules like `.codex/` — git refuses to look inside an
|
|
371
|
+
* excluded directory regardless of subsequent `!` rules).
|
|
372
|
+
*
|
|
373
|
+
* Skips:
|
|
374
|
+
* - managed-block kind (`.gitignore` itself isn't normally tracked)
|
|
375
|
+
* - blocked-edited / diff actions (file content is user's, not ours)
|
|
376
|
+
*
|
|
377
|
+
* @param {{repoRoot: string, checkIgnore?: (root: string, p: string) => boolean}} opts
|
|
378
|
+
* @param {Array} actions
|
|
379
|
+
* @returns {string[]} list of dest paths still ignored
|
|
380
|
+
*/
|
|
381
|
+
function verifyIgnoreStatus(opts, actions) {
|
|
382
|
+
const checkIgnore = opts.checkIgnore ?? DEFAULT_CHECK_IGNORE;
|
|
383
|
+
const stillIgnored = [];
|
|
384
|
+
|
|
385
|
+
for (const action of actions) {
|
|
386
|
+
if (action.template.kind === 'managed-block') continue;
|
|
387
|
+
if (action.action === 'blocked-edited' || action.action === 'diff') continue;
|
|
388
|
+
|
|
389
|
+
if (checkIgnore(opts.repoRoot, action.template.dest)) {
|
|
390
|
+
stillIgnored.push(action.template.dest);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return stillIgnored;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Build a stderr WARNING block for paths still ignored after applyActions.
|
|
398
|
+
*
|
|
399
|
+
* Most common cause: dir-only patterns (`.codex/`, `.claude/`) — git docs
|
|
400
|
+
* explicitly forbid re-include of files under an excluded directory. The
|
|
401
|
+
* fix is user-side, so we surface the exact paths and the change they need.
|
|
402
|
+
*/
|
|
403
|
+
function buildIgnoreWarning(paths) {
|
|
404
|
+
return [
|
|
405
|
+
'',
|
|
406
|
+
`init-repo: WARN: ${paths.length} scaffolded file${paths.length === 1 ? '' : 's'} still git-ignored:`,
|
|
407
|
+
...paths.map((p) => ` - ${p}`),
|
|
408
|
+
'',
|
|
409
|
+
'Your .gitignore has rules that block our managed re-include — most likely',
|
|
410
|
+
'dir-only patterns (e.g. `.codex/`, `.claude/`, `.cursor/`). Per gitignore',
|
|
411
|
+
'docs, files cannot be re-included once a parent directory is excluded.',
|
|
412
|
+
'',
|
|
413
|
+
'Fix: change those rules to use a trailing `*` so they ignore CONTENTS rather',
|
|
414
|
+
'than the directory itself, then re-run `aw init-repo`. Examples:',
|
|
415
|
+
' .codex/ → .codex/*',
|
|
416
|
+
' .claude/ → .claude/*',
|
|
417
|
+
' .cursor/ → .cursor/*',
|
|
418
|
+
'',
|
|
419
|
+
].join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
220
422
|
/**
|
|
221
423
|
* Format a single action line for the summary block.
|
|
222
424
|
*
|
|
@@ -275,6 +477,8 @@ function buildSummary({ repoRoot, dryRun, actions, blockedCount, exitCode }) {
|
|
|
275
477
|
* force?: boolean,
|
|
276
478
|
* diff?: boolean,
|
|
277
479
|
* dryRun?: boolean,
|
|
480
|
+
* noGitignore?: boolean,
|
|
481
|
+
* checkIgnore?: (root: string, p: string) => boolean,
|
|
278
482
|
* fs?: object,
|
|
279
483
|
* writer?: object,
|
|
280
484
|
* }} opts
|
|
@@ -315,9 +519,16 @@ export function initRepo(opts) {
|
|
|
315
519
|
return { exitCode: 2, summary: err.message, actions: [] };
|
|
316
520
|
}
|
|
317
521
|
|
|
522
|
+
// --no-gitignore opt-out: drop every managed-block template before planning.
|
|
523
|
+
// Users who manage their .gitignore by hand can pass this flag to keep
|
|
524
|
+
// init-repo from injecting the cloud-bootstrap re-include block.
|
|
525
|
+
const filteredManifest = opts.noGitignore
|
|
526
|
+
? { ...manifest, templates: manifest.templates.filter(t => t.kind !== 'managed-block') }
|
|
527
|
+
: manifest;
|
|
528
|
+
|
|
318
529
|
const actions = planActions(
|
|
319
530
|
{ repoRoot, templatesDir, force: opts.force, diff: opts.diff, fs },
|
|
320
|
-
|
|
531
|
+
filteredManifest,
|
|
321
532
|
);
|
|
322
533
|
|
|
323
534
|
applyActions(
|
|
@@ -338,5 +549,19 @@ export function initRepo(opts) {
|
|
|
338
549
|
|
|
339
550
|
writer.stdout(summary + '\n');
|
|
340
551
|
|
|
552
|
+
// Post-apply: verify that scaffolded paths are actually trackable by git.
|
|
553
|
+
// Emitted AFTER the summary so the action list always renders first
|
|
554
|
+
// regardless of stdout/stderr buffering. Skip on dryRun — nothing was
|
|
555
|
+
// written, so check-ignore would probe stale state and emit noise.
|
|
556
|
+
if (!opts.dryRun) {
|
|
557
|
+
const stillIgnored = verifyIgnoreStatus(
|
|
558
|
+
{ repoRoot, checkIgnore: opts.checkIgnore },
|
|
559
|
+
actions,
|
|
560
|
+
);
|
|
561
|
+
if (stillIgnored.length > 0) {
|
|
562
|
+
writer.stderr(buildIgnoreWarning(stillIgnored) + '\n');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
341
566
|
return { exitCode, summary, actions };
|
|
342
567
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Cloud bootstrap files — re-include for tracking even when parent dirs are
|
|
2
|
+
# ignored (e.g. .codex/*, .claude/*, .cursor/*). Order matters: a file under
|
|
3
|
+
# an excluded directory cannot be un-ignored without first un-ignoring the
|
|
4
|
+
# directory. The `<dir>/*` lines re-ignore everything inside, and the final
|
|
5
|
+
# `!<file>` line whitelists just our specific file.
|
|
6
|
+
!scripts/aw-c4-bootstrap.sh
|
|
7
|
+
!.codex/config.toml
|
|
8
|
+
!.cursor/environment.json
|
|
9
|
+
!.codex/scripts/
|
|
10
|
+
.codex/scripts/*
|
|
11
|
+
!.codex/scripts/codex-web-bootstrap.sh
|
|
12
|
+
!.claude/scripts/
|
|
13
|
+
.claude/scripts/*
|
|
14
|
+
!.claude/scripts/claude-web-bootstrap.sh
|
|
@@ -24,6 +24,18 @@
|
|
|
24
24
|
"src": "cursor/environment.json",
|
|
25
25
|
"dest": ".cursor/environment.json",
|
|
26
26
|
"mode": "0644"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"kind": "interpolated",
|
|
30
|
+
"src": "codex/config.toml",
|
|
31
|
+
"dest": ".codex/config.toml",
|
|
32
|
+
"mode": "0644"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"kind": "managed-block",
|
|
36
|
+
"src": "gitignore-block.txt",
|
|
37
|
+
"dest": ".gitignore",
|
|
38
|
+
"marker": "cloud-bootstrap"
|
|
27
39
|
}
|
|
28
40
|
]
|
|
29
41
|
}
|
package/commands/init-repo.mjs
CHANGED
|
@@ -16,16 +16,19 @@ const HELP = `
|
|
|
16
16
|
aw init-repo — scaffold cloud-bootstrap files into the current repo
|
|
17
17
|
|
|
18
18
|
Usage:
|
|
19
|
-
aw init-repo
|
|
20
|
-
aw init-repo --dry-run
|
|
21
|
-
aw init-repo --diff
|
|
22
|
-
aw init-repo --force
|
|
19
|
+
aw init-repo Scaffold 6 files (idempotent: skip-equal on byte match)
|
|
20
|
+
aw init-repo --dry-run Preview actions without writing
|
|
21
|
+
aw init-repo --diff Show diffs for locally-edited files (no write)
|
|
22
|
+
aw init-repo --force Overwrite locally-edited files
|
|
23
|
+
aw init-repo --no-gitignore Skip the .gitignore managed block (manage it by hand)
|
|
23
24
|
|
|
24
25
|
Files written:
|
|
25
|
-
scripts/aw-c4-bootstrap.sh (
|
|
26
|
+
scripts/aw-c4-bootstrap.sh (unified bootstrap, executable)
|
|
26
27
|
.codex/scripts/codex-web-bootstrap.sh (executable)
|
|
28
|
+
.codex/config.toml (codex hooks + per-repo trust)
|
|
27
29
|
.claude/scripts/claude-web-bootstrap.sh (executable)
|
|
28
30
|
.cursor/environment.json (cursor cloud config)
|
|
31
|
+
.gitignore (managed block — re-include of the 5 above)
|
|
29
32
|
|
|
30
33
|
Exit codes:
|
|
31
34
|
0 All files created or already in sync
|
|
@@ -35,8 +38,15 @@ Exit codes:
|
|
|
35
38
|
Notes:
|
|
36
39
|
- Idempotent. SHA-256 byte-compare per file. Re-running on a clean repo is a no-op.
|
|
37
40
|
- --namespace is NOT supported (use 'aw init --namespace <team>' for namespace pulls).
|
|
38
|
-
- .cursor/environment.json is
|
|
39
|
-
|
|
41
|
+
- .cursor/environment.json is a literal template; manually-added terminals or
|
|
42
|
+
run-on-build entries will be overwritten by --force. Use --diff first.
|
|
43
|
+
- .codex/config.toml has {{REPO_BASENAME}} substituted at scaffold time so the
|
|
44
|
+
[projects."/workspace/<repo>"] trust block matches Codex Cloud's checkout path.
|
|
45
|
+
- .gitignore: a marker-bracketed block re-includes the 5 cloud-bootstrap files
|
|
46
|
+
even when parent dirs are ignored. User content outside the markers is preserved.
|
|
47
|
+
A WARN with paste-ready fix is printed if pathological dir-only rules
|
|
48
|
+
(e.g. \`.codex/\`, \`.claude/\`) prevent re-include — change those to
|
|
49
|
+
\`.codex/*\` / \`.claude/*\` and re-run.
|
|
40
50
|
`.trim();
|
|
41
51
|
|
|
42
52
|
function printHelp() {
|
|
@@ -75,6 +85,7 @@ export async function initRepoCommand(args, deps = {}) {
|
|
|
75
85
|
dryRun: !!args['--dry-run'],
|
|
76
86
|
force: !!args['--force'],
|
|
77
87
|
diff: !!args['--diff'],
|
|
88
|
+
noGitignore: !!args['--no-gitignore'],
|
|
78
89
|
});
|
|
79
90
|
|
|
80
91
|
if (result.exitCode !== 0) {
|