@aipper/aiws 0.0.1 → 0.0.3
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 +63 -50
- package/package.json +2 -2
- package/src/cli.js +34 -3
- package/src/commands/change.js +457 -17
- package/src/template.js +46 -6
package/README.md
CHANGED
|
@@ -1,54 +1,67 @@
|
|
|
1
1
|
# @aipper/aiws
|
|
2
2
|
|
|
3
|
-
AI Workspace CLI
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
- 安装到 `~/.codex/prompts/`(或 `$CODEX_HOME/prompts`):`aiws codex install-prompts`
|
|
25
|
-
- 预演(不写入):`aiws codex install-prompts --dry-run`
|
|
26
|
-
- 状态检查:`aiws codex status`
|
|
27
|
-
- 卸载(仅移除 AIWS 托管 prompts;会备份):`aiws codex uninstall-prompts`
|
|
28
|
-
- 本仓库内可复现验证(不写入 home):`CODEX_HOME="$(mktemp -d)" node packages/aiws/bin/aiws.js codex install-prompts`
|
|
29
|
-
|
|
30
|
-
Git hooks 门禁(推荐):
|
|
31
|
-
- 启用(会自动补齐 `.githooks/*`,并设置 `core.hooksPath=.githooks`):`aiws hooks install .`
|
|
32
|
-
- 查看状态:`aiws hooks status .`
|
|
33
|
-
- 证据落盘(可选):`AIWS_VALIDATE_STAMP=1 aiws validate .` 会写入 `.agentdocs/tmp/aiws-validate/*.json`(`.agentdocs/` 被 `.gitignore` 忽略)
|
|
34
|
-
|
|
35
|
-
Change 工作流(脱离 dotfiles):
|
|
36
|
-
- 创建工件:`aiws change new <change-id>`(生成 `changes/<change-id>/{proposal,tasks,design}.md` 与 `.ws-change.json`)
|
|
37
|
-
- 切分支并初始化:`aiws change start <change-id> [--hooks]`
|
|
38
|
-
- 进度/建议:`aiws change list` / `aiws change status <change-id>` / `aiws change next <change-id>`
|
|
39
|
-
- 同步真值基线:`aiws change sync <change-id>`
|
|
40
|
-
- 严格校验:`aiws change validate <change-id> --strict`(在你把 `WS:TODO` 与归因补齐前,预期会失败)
|
|
41
|
-
- 归档:`aiws change archive <change-id>`
|
|
42
|
-
|
|
43
|
-
本仓库内可复现验证(不污染本仓库):
|
|
3
|
+
AI Workspace CLI:把 Claude Code / OpenCode / Codex / iFlow 对齐到同一套“真值文件 + 可审计工作流”,降低规则漂移。
|
|
4
|
+
|
|
5
|
+
核心能力:
|
|
6
|
+
- 初始化/更新模板:`aiws init` / `aiws update`
|
|
7
|
+
- 强门禁校验:`aiws validate`(可选证据落盘 `--stamp`)
|
|
8
|
+
- 回滚:`aiws rollback`(从 `.aiws/backups/` 恢复)
|
|
9
|
+
- 变更工件工作流(脱离 dotfiles):`aiws change ...`
|
|
10
|
+
- Git hooks:`aiws hooks install/status`
|
|
11
|
+
- Codex:repo skills(推荐)+ 可选全局 skills 安装
|
|
12
|
+
|
|
13
|
+
真值来源(SSOT):`@aipper/aiws-spec`(模板与契约)。
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
二选一:
|
|
18
|
+
- 全局安装(推荐,适合长期使用):`npm i -g @aipper/aiws`
|
|
19
|
+
- 临时运行(不污染全局版本):`npx @aipper/aiws <command>`
|
|
20
|
+
|
|
21
|
+
## 快速开始
|
|
22
|
+
|
|
23
|
+
在任意 git 仓库根目录:
|
|
44
24
|
```bash
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
node "$repo_root/packages/aiws/bin/aiws.js" change validate demo-change --strict
|
|
25
|
+
aiws init .
|
|
26
|
+
aiws hooks install .
|
|
27
|
+
|
|
28
|
+
# 建议:用 change 工作流生成可审计工件并切分支
|
|
29
|
+
aiws change start demo-change --no-design --hooks
|
|
30
|
+
|
|
31
|
+
# 提交前门禁(并落盘证据)
|
|
32
|
+
aiws validate . --stamp
|
|
54
33
|
```
|
|
34
|
+
|
|
35
|
+
## CLI 速查
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
aiws init [path] [--template <id>]
|
|
39
|
+
aiws update [path]
|
|
40
|
+
aiws validate [path] [--stamp]
|
|
41
|
+
aiws rollback [path] <timestamp|latest>
|
|
42
|
+
|
|
43
|
+
aiws change <list|new|start|status|next|sync|validate|archive|templates>
|
|
44
|
+
aiws hooks <install|status>
|
|
45
|
+
aiws codex <install-skills|status-skills|uninstall-skills|install-prompts|status|uninstall-prompts>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Codex(repo skills 优先)
|
|
49
|
+
|
|
50
|
+
- `aiws init .` 会生成 `.agents/skills/`(随仓库共享),在 Codex 中可显式调用(示例):`$ws-preflight` / `$ws-plan` / `$ws-dev` / `$ws-review` / `$ws-commit`
|
|
51
|
+
- 收尾(安全合并回目标分支):`$ws-finish`(底层调用 `aiws change finish`,默认 fast-forward)
|
|
52
|
+
- 可选:安装全局 skills 到 `~/.codex/skills/`(或 `$CODEX_HOME/skills`):
|
|
53
|
+
- `aiws codex install-skills`
|
|
54
|
+
- `aiws codex status-skills`
|
|
55
|
+
- `aiws codex uninstall-skills`
|
|
56
|
+
- legacy prompts(deprecated,仅兼容):`aiws codex install-prompts`
|
|
57
|
+
|
|
58
|
+
## 证据落盘与协作
|
|
59
|
+
|
|
60
|
+
- `aiws validate --stamp` 会写入:`.agentdocs/tmp/aiws-validate/*.json`(默认被 `.gitignore` 忽略)
|
|
61
|
+
- `$ws-review` 会落盘:`.agentdocs/tmp/review/codex-review.md`(默认被 `.gitignore` 忽略)
|
|
62
|
+
- 建议把“可协作/可审计”的内容放进 `changes/<change-id>/`(proposal/tasks/design)并提交;`.agentdocs/tmp/` 作为本地缓存更稳
|
|
63
|
+
|
|
64
|
+
## 运行要求
|
|
65
|
+
|
|
66
|
+
- Node.js:>= 20
|
|
67
|
+
- `aiws validate`:需要 `python3`(用于执行 `tools/ws_change_check.py` 与 `tools/requirements_contract.py`)
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aipper/aiws",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "AI Workspace CLI (init/update/validate) for Claude Code / OpenCode / Codex / iFlow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"aiws": "./bin/aiws.js"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@aipper/aiws-spec": "0.0.
|
|
10
|
+
"@aipper/aiws-spec": "0.0.3"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
package/src/cli.js
CHANGED
|
@@ -16,6 +16,7 @@ import { hooksInstallCommand } from "./commands/hooks-install.js";
|
|
|
16
16
|
import { hooksStatusCommand } from "./commands/hooks-status.js";
|
|
17
17
|
import {
|
|
18
18
|
changeArchiveCommand,
|
|
19
|
+
changeFinishCommand,
|
|
19
20
|
changeListCommand,
|
|
20
21
|
changeNewCommand,
|
|
21
22
|
changeNextCommand,
|
|
@@ -224,14 +225,28 @@ export async function cliMain(argv) {
|
|
|
224
225
|
title: { type: "string" },
|
|
225
226
|
"no-design": { type: "boolean" },
|
|
226
227
|
hooks: { type: "boolean" },
|
|
228
|
+
"no-switch": { type: "boolean" },
|
|
229
|
+
switch: { type: "boolean" },
|
|
230
|
+
worktree: { type: "boolean" },
|
|
231
|
+
"worktree-dir": { type: "string" },
|
|
232
|
+
submodules: { type: "boolean" },
|
|
227
233
|
});
|
|
228
234
|
const changeId = positionals[0] ?? "";
|
|
229
|
-
if (!changeId)
|
|
235
|
+
if (!changeId)
|
|
236
|
+
throw new UserError("change start requires <change-id>", {
|
|
237
|
+
details:
|
|
238
|
+
"Usage: aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>] [--submodules]",
|
|
239
|
+
});
|
|
230
240
|
await changeStartCommand({
|
|
231
241
|
changeId,
|
|
232
242
|
title: options.title,
|
|
233
243
|
noDesign: options["no-design"] === true,
|
|
234
244
|
enableHooks: options.hooks === true,
|
|
245
|
+
noSwitch: options["no-switch"] === true,
|
|
246
|
+
forceSwitch: options.switch === true,
|
|
247
|
+
worktree: options.worktree === true,
|
|
248
|
+
worktreeDir: options["worktree-dir"],
|
|
249
|
+
submodules: options.submodules === true,
|
|
235
250
|
});
|
|
236
251
|
return 0;
|
|
237
252
|
}
|
|
@@ -293,6 +308,19 @@ export async function cliMain(argv) {
|
|
|
293
308
|
});
|
|
294
309
|
return 0;
|
|
295
310
|
}
|
|
311
|
+
case "finish": {
|
|
312
|
+
const { positionals, options } = parseArgs(args, {
|
|
313
|
+
into: { type: "string" },
|
|
314
|
+
base: { type: "string" },
|
|
315
|
+
});
|
|
316
|
+
const changeId = positionals[0];
|
|
317
|
+
await changeFinishCommand({
|
|
318
|
+
changeId,
|
|
319
|
+
into: options.into,
|
|
320
|
+
base: options.base,
|
|
321
|
+
});
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
296
324
|
case "templates": {
|
|
297
325
|
const templatesSub = args.shift();
|
|
298
326
|
if (!templatesSub) throw new UserError("change templates requires <which|init>", { details: "Usage: aiws change templates which|init" });
|
|
@@ -378,7 +406,8 @@ function printChangeHelp() {
|
|
|
378
406
|
|
|
379
407
|
Usage:
|
|
380
408
|
aiws change list
|
|
381
|
-
aiws change start <change-id> [--title <title>] [--no-design] [--hooks]
|
|
409
|
+
aiws change start <change-id> [--title <title>] [--no-design] [--hooks] [--no-switch] [--switch] [--worktree] [--worktree-dir <path>] [--submodules]
|
|
410
|
+
aiws change finish [change-id] [--into <branch> | --base <branch>]
|
|
382
411
|
aiws change new <change-id> [--title <title>] [--no-design]
|
|
383
412
|
aiws change status [change-id]
|
|
384
413
|
aiws change next [change-id]
|
|
@@ -391,7 +420,9 @@ Usage:
|
|
|
391
420
|
Notes:
|
|
392
421
|
- change-id must be kebab-case: ^[a-z0-9]+(-[a-z0-9]+)*$
|
|
393
422
|
- If your git branch matches change/<change-id> (or changes/ws/ws-change prefixes),
|
|
394
|
-
you can omit <change-id> for status/next/validate/sync/archive.
|
|
423
|
+
you can omit <change-id> for status/next/validate/sync/archive/finish.
|
|
424
|
+
- If .gitmodules exists and you didn't specify --switch/--no-switch/--worktree,
|
|
425
|
+
start will prefer --worktree (fallback: --no-switch) to avoid switching the superproject branch.
|
|
395
426
|
- archive runs strict validation and (by default) requires all tasks checked.
|
|
396
427
|
- archive --force also bypasses truth drift gating (not recommended).
|
|
397
428
|
`);
|
package/src/commands/change.js
CHANGED
|
@@ -87,6 +87,122 @@ async function currentBranch(gitRoot) {
|
|
|
87
87
|
return String(res.stdout || "").trim();
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} gitRoot
|
|
92
|
+
*/
|
|
93
|
+
async function hasHeadCommit(gitRoot) {
|
|
94
|
+
return runCommand("git", ["rev-parse", "--verify", "HEAD"], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} gitRoot
|
|
99
|
+
* @returns {Promise<{ clean: true } | { clean: false, details: string }>}
|
|
100
|
+
*/
|
|
101
|
+
async function checkGitClean(gitRoot) {
|
|
102
|
+
const res = await runCommand("git", ["status", "--porcelain"], { cwd: gitRoot });
|
|
103
|
+
const out = String(res.stdout || "").trim();
|
|
104
|
+
if (!out) return { clean: true };
|
|
105
|
+
const lines = out.split("\n");
|
|
106
|
+
const truncated = lines.length > 20 ? `${lines.slice(0, 20).join("\n")}\n... (truncated)` : out;
|
|
107
|
+
return { clean: false, details: truncated };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} gitRoot
|
|
112
|
+
* @returns {Promise<{ ok: true } | { ok: false, reason: "no_commit" | "missing_truth", missing?: string[] }>}
|
|
113
|
+
*/
|
|
114
|
+
async function checkWorktreePrereqs(gitRoot) {
|
|
115
|
+
if (!(await hasHeadCommit(gitRoot))) return { ok: false, reason: "no_commit" };
|
|
116
|
+
/** @type {string[]} */
|
|
117
|
+
const missingInHead = [];
|
|
118
|
+
for (const rel of ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]) {
|
|
119
|
+
const ok = await runCommand("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
120
|
+
if (!ok) missingInHead.push(rel);
|
|
121
|
+
}
|
|
122
|
+
if (missingInHead.length > 0) return { ok: false, reason: "missing_truth", missing: missingInHead };
|
|
123
|
+
return { ok: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} changeDir
|
|
128
|
+
* @param {string} baseBranch
|
|
129
|
+
* @param {string} changeBranch
|
|
130
|
+
*/
|
|
131
|
+
async function maybeRecordBaseBranch(changeDir, baseBranch, changeBranch) {
|
|
132
|
+
const b = String(baseBranch || "").trim();
|
|
133
|
+
if (!b) return;
|
|
134
|
+
if (b === String(changeBranch || "").trim()) return;
|
|
135
|
+
const metaPath = path.join(changeDir, ".ws-change.json");
|
|
136
|
+
if (!(await pathExists(metaPath))) return;
|
|
137
|
+
/** @type {any} */
|
|
138
|
+
let meta = null;
|
|
139
|
+
try {
|
|
140
|
+
meta = JSON.parse(await readText(metaPath));
|
|
141
|
+
} catch {
|
|
142
|
+
meta = null;
|
|
143
|
+
}
|
|
144
|
+
if (!meta || typeof meta !== "object") return;
|
|
145
|
+
const cur = String(meta.base_branch || "").trim();
|
|
146
|
+
if (cur) return;
|
|
147
|
+
meta.base_branch = b;
|
|
148
|
+
meta.base_branch_set_at = nowIsoUtc();
|
|
149
|
+
await writeText(metaPath, JSON.stringify(meta, null, 2) + "\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string} gitRoot
|
|
154
|
+
* @returns {Promise<Array<{ worktree: string, head: string, branch: string }>>}
|
|
155
|
+
*/
|
|
156
|
+
async function listGitWorktrees(gitRoot) {
|
|
157
|
+
const res = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd: gitRoot });
|
|
158
|
+
if (res.code !== 0) {
|
|
159
|
+
throw new UserError("Failed to list git worktrees.", { details: res.stderr || res.stdout });
|
|
160
|
+
}
|
|
161
|
+
const lines = String(res.stdout || "")
|
|
162
|
+
.split(/\r?\n/)
|
|
163
|
+
.map((l) => l.trimEnd());
|
|
164
|
+
|
|
165
|
+
/** @type {Array<{ worktree: string, head: string, branch: string }>} */
|
|
166
|
+
const out = [];
|
|
167
|
+
/** @type {{ worktree: string, head: string, branch: string } | null} */
|
|
168
|
+
let cur = null;
|
|
169
|
+
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (!line) continue;
|
|
172
|
+
if (line.startsWith("worktree ")) {
|
|
173
|
+
if (cur) out.push(cur);
|
|
174
|
+
cur = { worktree: path.resolve(line.slice("worktree ".length).trim()), head: "", branch: "" };
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!cur) continue;
|
|
178
|
+
if (line.startsWith("HEAD ")) {
|
|
179
|
+
cur.head = line.slice("HEAD ".length).trim();
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (line.startsWith("branch ")) {
|
|
183
|
+
cur.branch = line.slice("branch ".length).trim();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (cur) out.push(cur);
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {string} gitRoot
|
|
193
|
+
* @param {string | undefined} worktreeDir
|
|
194
|
+
* @param {string} changeId
|
|
195
|
+
*/
|
|
196
|
+
function resolveDefaultWorktreeDir(gitRoot, worktreeDir, changeId) {
|
|
197
|
+
const parent = path.dirname(gitRoot);
|
|
198
|
+
const raw = String(worktreeDir || "").trim();
|
|
199
|
+
if (raw) {
|
|
200
|
+
return path.isAbsolute(raw) ? raw : path.resolve(parent, raw);
|
|
201
|
+
}
|
|
202
|
+
const repoName = path.basename(gitRoot);
|
|
203
|
+
return path.resolve(parent, `${repoName}-${changeId}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
90
206
|
/**
|
|
91
207
|
* @param {string} branch
|
|
92
208
|
* @returns {string}
|
|
@@ -508,12 +624,10 @@ export async function changeTemplatesInitCommand() {
|
|
|
508
624
|
}
|
|
509
625
|
|
|
510
626
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
* @param {{ changeId: string, title?: string, noDesign: boolean }} options
|
|
627
|
+
* @param {string} gitRoot
|
|
628
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean, baseBranch?: string }} options
|
|
514
629
|
*/
|
|
515
|
-
|
|
516
|
-
const gitRoot = await resolveGitRoot(process.cwd());
|
|
630
|
+
async function changeNewAtGitRoot(gitRoot, options) {
|
|
517
631
|
await ensureTruthFiles(gitRoot);
|
|
518
632
|
|
|
519
633
|
const changeId = String(options.changeId || "").trim();
|
|
@@ -551,6 +665,11 @@ export async function changeNewCommand(options) {
|
|
|
551
665
|
created_at: createdAt,
|
|
552
666
|
base_truth_files: truth,
|
|
553
667
|
};
|
|
668
|
+
const baseBranch = String(options.baseBranch || "").trim();
|
|
669
|
+
if (baseBranch) {
|
|
670
|
+
meta.base_branch = baseBranch;
|
|
671
|
+
meta.base_branch_set_at = createdAt;
|
|
672
|
+
}
|
|
554
673
|
await writeText(path.join(changeDir, ".ws-change.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
555
674
|
|
|
556
675
|
console.log(`✓ aiws change new: ${changeId}`);
|
|
@@ -563,10 +682,20 @@ export async function changeNewCommand(options) {
|
|
|
563
682
|
console.log(` - validate: aiws change validate ${changeId}`);
|
|
564
683
|
}
|
|
565
684
|
|
|
685
|
+
/**
|
|
686
|
+
* aiws change new
|
|
687
|
+
*
|
|
688
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean }} options
|
|
689
|
+
*/
|
|
690
|
+
export async function changeNewCommand(options) {
|
|
691
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
692
|
+
await changeNewAtGitRoot(gitRoot, options);
|
|
693
|
+
}
|
|
694
|
+
|
|
566
695
|
/**
|
|
567
696
|
* aiws change start
|
|
568
697
|
*
|
|
569
|
-
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean }} options
|
|
698
|
+
* @param {{ changeId: string, title?: string, noDesign: boolean, enableHooks: boolean, noSwitch: boolean, forceSwitch: boolean, worktree: boolean, worktreeDir?: string, submodules: boolean }} options
|
|
570
699
|
*/
|
|
571
700
|
export async function changeStartCommand(options) {
|
|
572
701
|
const gitRoot = await resolveGitRoot(process.cwd());
|
|
@@ -575,33 +704,220 @@ export async function changeStartCommand(options) {
|
|
|
575
704
|
const changeId = String(options.changeId || "").trim();
|
|
576
705
|
assertValidChangeId(changeId);
|
|
577
706
|
|
|
707
|
+
const startFromBranch = await currentBranch(gitRoot);
|
|
578
708
|
const branch = `change/${changeId}`;
|
|
709
|
+
const branchRef = `refs/heads/${branch}`;
|
|
710
|
+
|
|
711
|
+
if (options.worktree === true && options.noSwitch === true) {
|
|
712
|
+
throw new UserError("change start: cannot combine --worktree with --no-switch");
|
|
713
|
+
}
|
|
714
|
+
if (options.forceSwitch === true && options.noSwitch === true) {
|
|
715
|
+
throw new UserError("change start: cannot combine --switch with --no-switch");
|
|
716
|
+
}
|
|
717
|
+
if (options.forceSwitch === true && options.worktree === true) {
|
|
718
|
+
throw new UserError("change start: cannot combine --switch with --worktree");
|
|
719
|
+
}
|
|
579
720
|
|
|
580
721
|
const hasBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
722
|
+
const hasGitmodules = await pathExists(path.join(gitRoot, ".gitmodules"));
|
|
723
|
+
let effectiveNoSwitch = options.noSwitch === true;
|
|
724
|
+
const effectiveForceSwitch = options.forceSwitch === true;
|
|
725
|
+
let effectiveWorktree = options.worktree === true;
|
|
726
|
+
|
|
727
|
+
if (hasGitmodules && !effectiveNoSwitch && !effectiveWorktree && !effectiveForceSwitch) {
|
|
728
|
+
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
729
|
+
if (prereq.ok) {
|
|
730
|
+
effectiveWorktree = true;
|
|
731
|
+
console.error("warn: .gitmodules detected (git submodules). defaulting to --worktree to avoid switching the superproject branch.");
|
|
732
|
+
console.error("warn: to avoid worktree and just prepare artifacts: aiws change start <change-id> --no-switch");
|
|
733
|
+
console.error("warn: to switch anyway: aiws change start <change-id> --switch");
|
|
734
|
+
} else {
|
|
735
|
+
effectiveNoSwitch = true;
|
|
736
|
+
if (prereq.reason === "no_commit") {
|
|
737
|
+
console.error("warn: .gitmodules detected (git submodules). cannot use --worktree on a repo with no commits; defaulting to --no-switch.");
|
|
738
|
+
} else {
|
|
739
|
+
console.error("warn: .gitmodules detected (git submodules). cannot use --worktree without truth files committed in HEAD; defaulting to --no-switch.");
|
|
740
|
+
}
|
|
741
|
+
console.error("warn: to switch anyway: aiws change start <change-id> --switch");
|
|
742
|
+
console.error("warn: recommended for submodules: aiws change start <change-id> --worktree (after committing truth files)");
|
|
743
|
+
}
|
|
744
|
+
} else if (hasGitmodules && effectiveForceSwitch) {
|
|
745
|
+
console.error("warn: .gitmodules detected (git submodules). switching the superproject branch due to --switch.");
|
|
746
|
+
console.error("warn: recommended for submodules: aiws change start <change-id> --worktree");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (effectiveWorktree) {
|
|
750
|
+
const prereq = await checkWorktreePrereqs(gitRoot);
|
|
751
|
+
if (!prereq.ok) {
|
|
752
|
+
if (prereq.reason === "no_commit") {
|
|
753
|
+
throw new UserError("change start --worktree requires a repo with at least one commit.", { details: "Hint: make an initial commit, then retry." });
|
|
754
|
+
}
|
|
755
|
+
const missingInHead = Array.isArray(prereq.missing) ? prereq.missing : [];
|
|
756
|
+
throw new UserError("change start --worktree requires truth files committed in HEAD.", {
|
|
757
|
+
details:
|
|
758
|
+
`Missing in HEAD:\n${missingInHead.map((f) => `- ${f}`).join("\n")}\n\n` +
|
|
759
|
+
"Why: git worktree checks out from HEAD, so uncommitted files won't be present in the new worktree.\n" +
|
|
760
|
+
"Hint: commit the truth files first, or use `aiws change start --no-switch`.",
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
const worktreeDir = resolveDefaultWorktreeDir(gitRoot, options.worktreeDir, changeId);
|
|
764
|
+
|
|
765
|
+
const rel = path.relative(gitRoot, worktreeDir);
|
|
766
|
+
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
|
767
|
+
throw new UserError("worktree dir must be outside the git root.", {
|
|
768
|
+
details: `git_root: ${gitRoot}\nworktree_dir: ${worktreeDir}\nHint: use a sibling dir like ../<repo>-<change-id> (default) or pass --worktree-dir <path>.`,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const worktrees = await listGitWorktrees(gitRoot);
|
|
773
|
+
/** @type {Map<string, { worktree: string, head: string, branch: string }>} */
|
|
774
|
+
const byPath = new Map();
|
|
775
|
+
/** @type {Map<string, string>} */
|
|
776
|
+
const byBranch = new Map();
|
|
777
|
+
for (const w of worktrees) {
|
|
778
|
+
const k = path.resolve(w.worktree);
|
|
779
|
+
byPath.set(k, w);
|
|
780
|
+
if (w.branch) byBranch.set(w.branch, k);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const destKey = path.resolve(worktreeDir);
|
|
784
|
+
const destExisting = byPath.get(destKey);
|
|
785
|
+
|
|
786
|
+
if (!destExisting) {
|
|
787
|
+
if (await pathExists(destKey)) {
|
|
788
|
+
throw new UserError("worktree dir already exists and is not a registered worktree; refusing to overwrite.", {
|
|
789
|
+
details: `worktree_dir: ${destKey}`,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
const used = byBranch.get(branchRef);
|
|
793
|
+
if (used) {
|
|
794
|
+
throw new UserError("change branch is already checked out in another worktree.", {
|
|
795
|
+
details: `branch: ${branch}\nworktree: ${used}\nHint: use that worktree, or pick another change-id.`,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const args = hasBranch ? ["worktree", "add", destKey, branch] : ["worktree", "add", "-b", branch, destKey];
|
|
800
|
+
const add = await runCommand("git", args, { cwd: gitRoot });
|
|
801
|
+
if (add.code !== 0) {
|
|
802
|
+
throw new UserError("Failed to create git worktree.", { details: add.stderr || add.stdout });
|
|
803
|
+
}
|
|
804
|
+
} else if (destExisting.branch && destExisting.branch !== branchRef) {
|
|
805
|
+
throw new UserError("worktree dir already exists but is on a different branch.", {
|
|
806
|
+
details: `worktree_dir: ${destKey}\nexpected_branch: ${branchRef}\nactual_branch: ${destExisting.branch}`,
|
|
807
|
+
});
|
|
808
|
+
} else if (!destExisting.branch) {
|
|
809
|
+
throw new UserError("worktree dir already exists but is detached; refusing to proceed.", {
|
|
810
|
+
details: `worktree_dir: ${destKey}\nHint: inspect with: git -C ${destKey} status`,
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const changeDir = changeDirAbs(destKey, changeId);
|
|
815
|
+
if (!(await pathExists(changeDir))) {
|
|
816
|
+
await changeNewAtGitRoot(destKey, {
|
|
817
|
+
changeId,
|
|
818
|
+
title: options.title,
|
|
819
|
+
noDesign: options.noDesign,
|
|
820
|
+
baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
|
|
821
|
+
});
|
|
822
|
+
} else {
|
|
823
|
+
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (options.submodules === true) {
|
|
827
|
+
const sm = await runCommand("git", ["submodule", "update", "--init", "--recursive"], { cwd: destKey });
|
|
828
|
+
if (sm.code !== 0) throw new UserError("Failed to init/update submodules in worktree.", { details: sm.stderr || sm.stdout });
|
|
829
|
+
} else if (hasGitmodules) {
|
|
830
|
+
console.error("warn: .gitmodules detected; new worktree may have empty submodule dirs.");
|
|
831
|
+
console.error("warn: consider running in the worktree: git submodule update --init --recursive (or re-run with --submodules)");
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (options.enableHooks) {
|
|
835
|
+
await hooksInstallCommand({ targetPath: gitRoot });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
console.log(`ok: change worktree ready: ${changeId}`);
|
|
839
|
+
console.log(`worktree: ${destKey}`);
|
|
840
|
+
console.log(`branch: ${branch}`);
|
|
841
|
+
console.log("next:");
|
|
842
|
+
console.log(` - cd: cd ${destKey}`);
|
|
843
|
+
console.log(" - status: aiws change status");
|
|
844
|
+
console.log(" - next: aiws change next");
|
|
845
|
+
console.log(" - validate: aiws change validate --strict");
|
|
846
|
+
if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
|
|
847
|
+
console.log("finish (safe merge):");
|
|
848
|
+
console.log(` - from main worktree: git merge --ff-only ${branch}`);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const headReady = await hasHeadCommit(gitRoot);
|
|
853
|
+
let branchReady = hasBranch;
|
|
854
|
+
|
|
855
|
+
if (effectiveNoSwitch) {
|
|
856
|
+
if (!hasBranch) {
|
|
857
|
+
if (!headReady) {
|
|
858
|
+
// On an unborn branch, `git branch <name>` fails because HEAD doesn't point to a commit yet.
|
|
859
|
+
// We still allow initializing change artifacts without switching branches.
|
|
860
|
+
console.error(`warn: repo has no commits; cannot create branch "${branch}" without switching (skipped)`);
|
|
861
|
+
branchReady = false;
|
|
862
|
+
} else {
|
|
863
|
+
const br = await runCommand("git", ["branch", branch], { cwd: gitRoot });
|
|
864
|
+
if (br.code !== 0) throw new UserError("Failed to create branch.", { details: br.stderr || br.stdout });
|
|
865
|
+
branchReady = true;
|
|
866
|
+
}
|
|
586
867
|
}
|
|
587
868
|
} else {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
869
|
+
if (hasBranch) {
|
|
870
|
+
const sw = await runCommand("git", ["switch", branch], { cwd: gitRoot });
|
|
871
|
+
if (sw.code !== 0) {
|
|
872
|
+
const co = await runCommand("git", ["checkout", branch], { cwd: gitRoot });
|
|
873
|
+
if (co.code !== 0) throw new UserError("Failed to switch branch.", { details: co.stderr || co.stdout });
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
const sw = await runCommand("git", ["switch", "-c", branch], { cwd: gitRoot });
|
|
877
|
+
if (sw.code !== 0) {
|
|
878
|
+
const co = await runCommand("git", ["checkout", "-b", branch], { cwd: gitRoot });
|
|
879
|
+
if (co.code !== 0) throw new UserError("Failed to create branch.", { details: co.stderr || co.stdout });
|
|
880
|
+
}
|
|
592
881
|
}
|
|
882
|
+
branchReady = true;
|
|
593
883
|
}
|
|
594
884
|
|
|
595
885
|
const changeDir = changeDirAbs(gitRoot, changeId);
|
|
596
886
|
if (!(await pathExists(changeDir))) {
|
|
597
|
-
await
|
|
887
|
+
await changeNewAtGitRoot(gitRoot, {
|
|
888
|
+
changeId,
|
|
889
|
+
title: options.title,
|
|
890
|
+
noDesign: options.noDesign,
|
|
891
|
+
baseBranch: startFromBranch && startFromBranch !== branch ? startFromBranch : undefined,
|
|
892
|
+
});
|
|
893
|
+
} else {
|
|
894
|
+
await maybeRecordBaseBranch(changeDir, startFromBranch, branch);
|
|
598
895
|
}
|
|
599
896
|
|
|
600
897
|
if (options.enableHooks) {
|
|
601
898
|
await hooksInstallCommand({ targetPath: gitRoot });
|
|
602
899
|
}
|
|
603
900
|
|
|
604
|
-
|
|
901
|
+
const cur = await currentBranch(gitRoot);
|
|
902
|
+
const curLabel = cur || "(detached)";
|
|
903
|
+
if (effectiveNoSwitch && cur !== branch) {
|
|
904
|
+
console.log(`ok: prepared change: ${changeId} (branch: ${branch}${branchReady ? "" : " (pending)"}; current: ${curLabel})`);
|
|
905
|
+
console.log("next:");
|
|
906
|
+
if (branchReady) {
|
|
907
|
+
console.log(` - switch: git switch ${branch}`);
|
|
908
|
+
} else {
|
|
909
|
+
console.log(" - note: repo has no commits; create the branch after your first commit");
|
|
910
|
+
console.log(` - create: git branch ${branch}`);
|
|
911
|
+
console.log(` - switch: git switch ${branch}`);
|
|
912
|
+
}
|
|
913
|
+
console.log(` - status: aiws change status ${changeId}`);
|
|
914
|
+
console.log(` - next: aiws change next ${changeId}`);
|
|
915
|
+
console.log(` - validate: aiws change validate ${changeId} --strict`);
|
|
916
|
+
if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
console.log(`ok: active change: ${changeId} (branch: ${branch}; current: ${curLabel})`);
|
|
605
921
|
console.log("next:");
|
|
606
922
|
console.log(" - status: aiws change status");
|
|
607
923
|
console.log(" - next: aiws change next");
|
|
@@ -609,6 +925,130 @@ export async function changeStartCommand(options) {
|
|
|
609
925
|
if (!options.enableHooks) console.log(" - (optional) enable hooks: aiws hooks install .");
|
|
610
926
|
}
|
|
611
927
|
|
|
928
|
+
/**
|
|
929
|
+
* aiws change finish
|
|
930
|
+
*
|
|
931
|
+
* @param {{ changeId?: string, into?: string, base?: string }} options
|
|
932
|
+
*/
|
|
933
|
+
export async function changeFinishCommand(options) {
|
|
934
|
+
const gitRoot = await resolveGitRoot(process.cwd());
|
|
935
|
+
await ensureTruthFiles(gitRoot);
|
|
936
|
+
|
|
937
|
+
const changeId = await resolveChangeId(gitRoot, options.changeId, { command: "finish" });
|
|
938
|
+
assertValidChangeId(changeId);
|
|
939
|
+
|
|
940
|
+
const changeBranch = `change/${changeId}`;
|
|
941
|
+
const changeBranchRef = `refs/heads/${changeBranch}`;
|
|
942
|
+
|
|
943
|
+
const hasChangeBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", changeBranchRef], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
944
|
+
if (!hasChangeBranch) {
|
|
945
|
+
throw new UserError(`Missing change branch: ${changeBranch}`, {
|
|
946
|
+
details: "Hint: create it with `aiws change start <change-id>` (or `git switch -c change/<id>`).",
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const clean = await checkGitClean(gitRoot);
|
|
951
|
+
if (!clean.clean) {
|
|
952
|
+
throw new UserError("Refusing to finish with a dirty working tree.", {
|
|
953
|
+
details: `${clean.details}\n\nHint: commit or stash your changes, then retry.`,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const cur = await currentBranch(gitRoot);
|
|
958
|
+
const intoRaw = String(options.into || "").trim();
|
|
959
|
+
const baseRaw = String(options.base || "").trim();
|
|
960
|
+
if (intoRaw && baseRaw && intoRaw !== baseRaw) {
|
|
961
|
+
throw new UserError("change finish: cannot combine --into with --base (values differ).", { details: `--into=${intoRaw}\n--base=${baseRaw}` });
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** @type {string} */
|
|
965
|
+
let into = intoRaw || baseRaw;
|
|
966
|
+
|
|
967
|
+
if (!into) {
|
|
968
|
+
if (!cur) {
|
|
969
|
+
throw new UserError("Detached HEAD: cannot infer target branch for finish.", {
|
|
970
|
+
details: "Hint: switch to the target branch (e.g. main) and retry, or pass `aiws change finish <id> --into <branch>`.",
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
if (cur !== changeBranch) {
|
|
974
|
+
into = cur;
|
|
975
|
+
} else {
|
|
976
|
+
const metaPath = path.join(gitRoot, "changes", changeId, ".ws-change.json");
|
|
977
|
+
/** @type {any} */
|
|
978
|
+
let meta = null;
|
|
979
|
+
if (await pathExists(metaPath)) {
|
|
980
|
+
try {
|
|
981
|
+
meta = JSON.parse(await readText(metaPath));
|
|
982
|
+
} catch {
|
|
983
|
+
meta = null;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (!meta) {
|
|
987
|
+
const show = await runCommand("git", ["show", `${changeBranch}:changes/${changeId}/.ws-change.json`], { cwd: gitRoot });
|
|
988
|
+
if (show.code === 0) {
|
|
989
|
+
try {
|
|
990
|
+
meta = JSON.parse(String(show.stdout || ""));
|
|
991
|
+
} catch {
|
|
992
|
+
meta = null;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const inferred = meta && typeof meta === "object" ? String(meta.base_branch || "").trim() : "";
|
|
997
|
+
if (!inferred) {
|
|
998
|
+
throw new UserError("Cannot infer base branch for finish.", {
|
|
999
|
+
details: "Hint: run from the target branch (e.g. main), or pass: `aiws change finish <id> --into <branch>`.",
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
into = inferred;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (into === changeBranch) {
|
|
1007
|
+
throw new UserError("change finish: target branch cannot be the change branch.", { details: `target=${into}` });
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const worktrees = await listGitWorktrees(gitRoot);
|
|
1011
|
+
const intoRef = `refs/heads/${into}`;
|
|
1012
|
+
const intoWt = worktrees.find((w) => String(w.branch || "") === intoRef);
|
|
1013
|
+
if (intoWt && path.resolve(intoWt.worktree) !== path.resolve(gitRoot)) {
|
|
1014
|
+
throw new UserError("Target branch is checked out in another worktree.", {
|
|
1015
|
+
details: `branch: ${into}\nworktree: ${intoWt.worktree}\n\nHint: run finish in that worktree:\n cd ${intoWt.worktree}\n aiws change finish ${changeId}`,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const hasIntoBranch = await runCommand("git", ["show-ref", "--verify", "--quiet", intoRef], { cwd: gitRoot }).then((r) => r.code === 0);
|
|
1020
|
+
if (!hasIntoBranch) {
|
|
1021
|
+
throw new UserError("Target branch does not exist.", { details: `branch: ${into}` });
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (cur && cur !== into) {
|
|
1025
|
+
const sw = await runCommand("git", ["switch", into], { cwd: gitRoot });
|
|
1026
|
+
if (sw.code !== 0) {
|
|
1027
|
+
const co = await runCommand("git", ["checkout", into], { cwd: gitRoot });
|
|
1028
|
+
if (co.code !== 0) throw new UserError("Failed to switch target branch.", { details: co.stderr || co.stdout });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const merge = await runCommand("git", ["merge", "--ff-only", changeBranch], { cwd: gitRoot });
|
|
1033
|
+
if (merge.code !== 0) {
|
|
1034
|
+
const changeRef = changeBranchRef;
|
|
1035
|
+
const changeWt = worktrees.find((w) => String(w.branch || "") === changeRef);
|
|
1036
|
+
const extra =
|
|
1037
|
+
changeWt && changeWt.worktree
|
|
1038
|
+
? `\n\nIf not fast-forward, rebase the change branch then retry:\n cd ${changeWt.worktree}\n git rebase ${into}\n cd ${gitRoot}\n aiws change finish ${changeId}`
|
|
1039
|
+
: `\n\nIf not fast-forward, rebase the change branch then retry:\n git switch ${changeBranch}\n git rebase ${into}\n git switch ${into}\n aiws change finish ${changeId}`;
|
|
1040
|
+
throw new UserError("Failed to fast-forward merge change branch into target.", { details: `${merge.stderr || merge.stdout}${extra}` });
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
console.log(`ok: finished change: ${changeId}`);
|
|
1044
|
+
console.log(`into: ${into}`);
|
|
1045
|
+
console.log(`from: ${changeBranch}`);
|
|
1046
|
+
if (await pathExists(path.join(gitRoot, ".gitmodules"))) {
|
|
1047
|
+
console.log("next:");
|
|
1048
|
+
console.log(" - (optional) update submodules: git submodule update --init --recursive");
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
612
1052
|
/**
|
|
613
1053
|
* aiws change status
|
|
614
1054
|
*
|
package/src/template.js
CHANGED
|
@@ -6,6 +6,40 @@ import { normalizeNewlines } from "./hash.js";
|
|
|
6
6
|
import { extractTemplateBlock, upsertManagedBlock } from "./managed-blocks.js";
|
|
7
7
|
import { UserError } from "./errors.js";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Template file candidates for a given manifest/workspace path.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: npm excludes files named `.gitignore` from published tarballs, even when placed under templates/.
|
|
13
|
+
* We store it as `gitignore` in templates and map it back at runtime.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} relPosix
|
|
16
|
+
* @returns {string[]}
|
|
17
|
+
*/
|
|
18
|
+
function templateRelCandidates(relPosix) {
|
|
19
|
+
const rel = normalizeRel(relPosix);
|
|
20
|
+
if (!rel) return [];
|
|
21
|
+
/** @type {string[]} */
|
|
22
|
+
const out = [rel];
|
|
23
|
+
if (rel === ".gitignore" || rel.endsWith("/.gitignore")) {
|
|
24
|
+
out.push(rel.replace(/(^|\/)\.gitignore$/, "$1gitignore"));
|
|
25
|
+
}
|
|
26
|
+
return Array.from(new Set(out));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a template source file path for a given workspace-relative path.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} templateDir
|
|
33
|
+
* @param {string} relPosix
|
|
34
|
+
*/
|
|
35
|
+
async function resolveTemplateSourcePath(templateDir, relPosix) {
|
|
36
|
+
for (const cand of templateRelCandidates(relPosix)) {
|
|
37
|
+
const abs = templatePath(templateDir, cand);
|
|
38
|
+
if (await pathExists(abs)) return abs;
|
|
39
|
+
}
|
|
40
|
+
return templatePath(templateDir, relPosix);
|
|
41
|
+
}
|
|
42
|
+
|
|
9
43
|
/**
|
|
10
44
|
* Expand manifest paths, supporting patterns ending with `/**`.
|
|
11
45
|
*
|
|
@@ -48,9 +82,13 @@ export function templatePath(templateDir, relPosix) {
|
|
|
48
82
|
* @param {{ templateDir: string, workspaceRoot: string, relPosix: string, chmod?: number }} options
|
|
49
83
|
*/
|
|
50
84
|
export async function copyTemplateFileToWorkspace(options) {
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
85
|
+
const rel = normalizeRel(options.relPosix);
|
|
86
|
+
const src = await resolveTemplateSourcePath(options.templateDir, rel);
|
|
87
|
+
const dest = joinRel(options.workspaceRoot, rel);
|
|
88
|
+
if (!(await pathExists(src))) {
|
|
89
|
+
const tried = templateRelCandidates(rel).map((c) => templatePath(options.templateDir, c));
|
|
90
|
+
throw new UserError(`Template file missing: ${options.relPosix}`, { details: `Tried:\n- ${tried.join("\n- ")}` });
|
|
91
|
+
}
|
|
54
92
|
await copyFile(src, dest, typeof options.chmod === "number" ? { chmod: options.chmod } : undefined);
|
|
55
93
|
}
|
|
56
94
|
|
|
@@ -63,9 +101,12 @@ export async function copyTemplateFileToWorkspace(options) {
|
|
|
63
101
|
export async function applyManagedBlocksFromTemplate(options) {
|
|
64
102
|
const fileRel = normalizeRel(options.fileRel);
|
|
65
103
|
const dest = joinRel(options.workspaceRoot, fileRel);
|
|
66
|
-
const src =
|
|
104
|
+
const src = await resolveTemplateSourcePath(options.templateDir, fileRel);
|
|
67
105
|
|
|
68
|
-
if (!(await pathExists(src)))
|
|
106
|
+
if (!(await pathExists(src))) {
|
|
107
|
+
const tried = templateRelCandidates(fileRel).map((c) => templatePath(options.templateDir, c));
|
|
108
|
+
throw new UserError(`Template file missing: ${fileRel}`, { details: `Tried:\n- ${tried.join("\n- ")}` });
|
|
109
|
+
}
|
|
69
110
|
const templateText = normalizeNewlines(await readText(src));
|
|
70
111
|
|
|
71
112
|
if (!(await pathExists(dest))) {
|
|
@@ -104,4 +145,3 @@ export async function ensureWorkspaceDir(workspaceRoot, relPosix) {
|
|
|
104
145
|
const abs = joinRel(workspaceRoot, relPosix);
|
|
105
146
|
await fs.mkdir(abs, { recursive: true });
|
|
106
147
|
}
|
|
107
|
-
|