phasegate 0.145.0 → 0.145.2
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 +14 -0
- package/package.json +1 -1
- package/scripts/harness/installation/application/ports/file-inspector-port.ts +3 -7
- package/scripts/harness/installation/application/usecases/run-install.ts +433 -0
- package/scripts/harness/installation/composition-root.ts +19 -0
- package/scripts/harness/installation/domain/ports/file-inspector.ts +11 -0
- package/scripts/harness/installation/domain/ports/heuristic-check.ts +2 -2
- package/scripts/harness/installation/presentation/cli/install-handler.ts +50 -0
- package/scripts/harness/main.ts +119 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.145.2] - 2026-05-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **WI-146 — structured install** — `phasegate install --dry-run|--apply [--force] [--json]` を追加し、既存設定を preserve しながら Claude / Codex hooks、Husky hooks、CI workflow、package scripts/devDependency、skills link、manifest entries を構造化 merge できるようにした。
|
|
15
|
+
- 既存カスタム hook は既定では拒否し、`--force` 時は `.phasegate/backups/` に退避してから managed block を適用する。
|
|
16
|
+
|
|
17
|
+
## [0.145.1] - 2026-05-11
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **WI-145 post-publish dogfood** — `npx phasegate@0.145.0 init` 直後の `doctor` が `package-json-devdep-missing` で RED になる問題を修正した。
|
|
22
|
+
- `init` が `package.json` を作成または更新し、`devDependencies.phasegate` を現在の harness version で登録するようにした。
|
|
23
|
+
|
|
10
24
|
## [0.145.0] - 2026-05-11
|
|
11
25
|
|
|
12
26
|
### Added
|
package/package.json
CHANGED
|
@@ -2,10 +2,6 @@
|
|
|
2
2
|
// @layer application
|
|
3
3
|
// @work-item-id WI-145
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readJson<T = unknown>(absolutePath: string): Promise<T | null>;
|
|
9
|
-
readSymlink(absolutePath: string): Promise<string | null>;
|
|
10
|
-
listFiles(absolutePath: string): Promise<string[]>;
|
|
11
|
-
}
|
|
5
|
+
import type { FileInspector } from "../../domain/ports/file-inspector.js";
|
|
6
|
+
|
|
7
|
+
export type FileInspectorPort = FileInspector;
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer application
|
|
3
|
+
// @work-item-id WI-146
|
|
4
|
+
|
|
5
|
+
import { mkdir, readFile, writeFile, copyFile, chmod, access, lstat, readlink, symlink } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { DeploymentEntry } from "../../domain/deployment-entry.js";
|
|
8
|
+
import { DeploymentManifest } from "../../domain/deployment-manifest.js";
|
|
9
|
+
import type { ManagedBlockInput } from "../../domain/managed-block.js";
|
|
10
|
+
import type { RepairMode } from "../../domain/repair-mode.js";
|
|
11
|
+
import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
|
|
12
|
+
import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
|
|
13
|
+
|
|
14
|
+
type InstallAction = "missing" | "will-merge" | "will-skip" | "will-overwrite";
|
|
15
|
+
type StrategyType = "json" | "shell" | "yaml-add" | "package-json";
|
|
16
|
+
|
|
17
|
+
export interface InstallPlanItem {
|
|
18
|
+
readonly path: string;
|
|
19
|
+
readonly action: InstallAction;
|
|
20
|
+
readonly repairMode: RepairMode;
|
|
21
|
+
readonly strategy: StrategyType;
|
|
22
|
+
readonly changed: boolean;
|
|
23
|
+
readonly summary: string;
|
|
24
|
+
readonly diff: string;
|
|
25
|
+
readonly skillHint: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunInstallInput {
|
|
29
|
+
readonly projectRoot: string;
|
|
30
|
+
readonly harnessRoot: string;
|
|
31
|
+
readonly phasegateVersion: string;
|
|
32
|
+
readonly dryRun: boolean;
|
|
33
|
+
readonly apply: boolean;
|
|
34
|
+
readonly force: boolean;
|
|
35
|
+
readonly includeClaude?: boolean;
|
|
36
|
+
readonly includeCodex?: boolean;
|
|
37
|
+
readonly includeHusky?: boolean;
|
|
38
|
+
readonly includeCi?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RunInstallResult {
|
|
42
|
+
readonly plan: readonly InstallPlanItem[];
|
|
43
|
+
readonly refused: readonly InstallPlanItem[];
|
|
44
|
+
readonly changed: readonly InstallPlanItem[];
|
|
45
|
+
readonly backupDir: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface InstallTarget {
|
|
49
|
+
readonly path: string;
|
|
50
|
+
readonly strategy: StrategyType;
|
|
51
|
+
readonly templatePath: string;
|
|
52
|
+
readonly executable?: boolean;
|
|
53
|
+
readonly block?: ManagedBlockInput;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SKILL_HINT = "invoke /phasegate-config-doctor";
|
|
57
|
+
const PHASEGATE_SCRIPT_VERSION = "^0.0.0";
|
|
58
|
+
|
|
59
|
+
const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
|
|
60
|
+
const SHELL_END = "# === phasegate managed (END) ===";
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function exists(path: string): Promise<boolean> {
|
|
67
|
+
try {
|
|
68
|
+
await access(path);
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readTextOrNull(path: string): Promise<string | null> {
|
|
76
|
+
try {
|
|
77
|
+
return await readFile(path, "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeJsonEntry(value: unknown): string {
|
|
84
|
+
return JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mergeHookArrays(existing: unknown, incoming: unknown): unknown[] {
|
|
88
|
+
const result = Array.isArray(existing) ? [...existing] : [];
|
|
89
|
+
const seen = new Set(result.map((entry) => normalizeJsonEntry(entry)));
|
|
90
|
+
for (const entry of Array.isArray(incoming) ? incoming : []) {
|
|
91
|
+
const key = normalizeJsonEntry(entry);
|
|
92
|
+
if (!seen.has(key)) {
|
|
93
|
+
result.push(entry);
|
|
94
|
+
seen.add(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mergeJsonObject(existing: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
|
|
101
|
+
const result: Record<string, unknown> = { ...existing };
|
|
102
|
+
const existingHooks = isRecord(existing.hooks) ? existing.hooks : {};
|
|
103
|
+
const incomingHooks = isRecord(incoming.hooks) ? incoming.hooks : {};
|
|
104
|
+
result.hooks = { ...existingHooks };
|
|
105
|
+
for (const [event, incomingEntries] of Object.entries(incomingHooks)) {
|
|
106
|
+
(result.hooks as Record<string, unknown>)[event] = mergeHookArrays(existingHooks[event], incomingEntries);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const existingPermissions = isRecord(existing.permissions) ? existing.permissions : {};
|
|
110
|
+
const incomingPermissions = isRecord(incoming.permissions) ? incoming.permissions : {};
|
|
111
|
+
if (Object.keys(incomingPermissions).length > 0 || Object.keys(existingPermissions).length > 0) {
|
|
112
|
+
const deny = [
|
|
113
|
+
...(Array.isArray(existingPermissions.deny) ? existingPermissions.deny : []),
|
|
114
|
+
...(Array.isArray(incomingPermissions.deny) ? incomingPermissions.deny : []),
|
|
115
|
+
];
|
|
116
|
+
result.permissions = {
|
|
117
|
+
...existingPermissions,
|
|
118
|
+
...incomingPermissions,
|
|
119
|
+
deny: [...new Set(deny)],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function mergeShell(existing: string | null, incoming: string): string {
|
|
126
|
+
if (existing === incoming || existing === `${incoming.trim()}\n`) return existing;
|
|
127
|
+
const block = `${SHELL_BEGIN}\n${incoming.trim()}\n${SHELL_END}`;
|
|
128
|
+
if (existing === null || existing.trim().length === 0) return `${incoming.trim()}\n`;
|
|
129
|
+
const pattern = new RegExp(`${escapeRegExp(SHELL_BEGIN)}[\\s\\S]*?${escapeRegExp(SHELL_END)}`);
|
|
130
|
+
if (pattern.test(existing)) return existing.replace(pattern, block).replace(/\s*$/, "\n");
|
|
131
|
+
return `${existing.replace(/\s*$/, "\n\n")}${block}\n`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function escapeRegExp(value: string): string {
|
|
135
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function mergePackageJson(existing: Record<string, unknown>, version: string): Record<string, unknown> {
|
|
139
|
+
const devDependencies = isRecord(existing.devDependencies) ? existing.devDependencies : {};
|
|
140
|
+
const scripts = isRecord(existing.scripts) ? existing.scripts : {};
|
|
141
|
+
return {
|
|
142
|
+
...existing,
|
|
143
|
+
scripts: {
|
|
144
|
+
...scripts,
|
|
145
|
+
"phasegate:lint": scripts["phasegate:lint"] ?? "phasegate lint",
|
|
146
|
+
"phasegate:check-ready": scripts["phasegate:check-ready"] ?? "phasegate phasegate:check-ready",
|
|
147
|
+
"phasegate:doctor": scripts["phasegate:doctor"] ?? "phasegate doctor",
|
|
148
|
+
},
|
|
149
|
+
devDependencies: {
|
|
150
|
+
...devDependencies,
|
|
151
|
+
phasegate: `^${version}`,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hasCustomJson(content: string | null): boolean {
|
|
157
|
+
if (content === null) return false;
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(content) as unknown;
|
|
160
|
+
if (!isRecord(parsed)) return true;
|
|
161
|
+
const withoutEmptyHooks = { ...parsed };
|
|
162
|
+
if (isRecord(withoutEmptyHooks.hooks) && Object.keys(withoutEmptyHooks.hooks).length === 0) delete withoutEmptyHooks.hooks;
|
|
163
|
+
return Object.keys(withoutEmptyHooks).length > 0;
|
|
164
|
+
} catch {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function shellRepairMode(content: string | null): RepairMode {
|
|
170
|
+
if (content === null || content.trim().length === 0 || content.includes(SHELL_BEGIN)) return "mechanical";
|
|
171
|
+
return "ai-assisted";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function jsonRepairMode(content: string | null): RepairMode {
|
|
175
|
+
if (content === null) return "mechanical";
|
|
176
|
+
try {
|
|
177
|
+
JSON.parse(content);
|
|
178
|
+
} catch {
|
|
179
|
+
return "manual";
|
|
180
|
+
}
|
|
181
|
+
return "mechanical";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class RunInstallUseCase {
|
|
185
|
+
constructor(
|
|
186
|
+
private readonly manifestRepository: ManifestRepositoryPort,
|
|
187
|
+
private readonly hashCalculator: HashCalculatorPort,
|
|
188
|
+
) {}
|
|
189
|
+
|
|
190
|
+
async execute(input: RunInstallInput): Promise<RunInstallResult> {
|
|
191
|
+
const includeClaude = input.includeClaude ?? true;
|
|
192
|
+
const includeCodex = input.includeCodex ?? true;
|
|
193
|
+
const includeHusky = input.includeHusky ?? true;
|
|
194
|
+
const includeCi = input.includeCi ?? true;
|
|
195
|
+
const targets = this.createTargets({ includeClaude, includeCodex, includeHusky, includeCi });
|
|
196
|
+
const existingManifest = await this.manifestRepository.load(input.projectRoot);
|
|
197
|
+
const baseManifest = existingManifest ?? DeploymentManifest.create(input.phasegateVersion);
|
|
198
|
+
let manifest = baseManifest;
|
|
199
|
+
const plan: InstallPlanItem[] = [];
|
|
200
|
+
const refused: InstallPlanItem[] = [];
|
|
201
|
+
const changed: InstallPlanItem[] = [];
|
|
202
|
+
const backupStamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
203
|
+
let backupDir: string | null = null;
|
|
204
|
+
|
|
205
|
+
for (const target of targets) {
|
|
206
|
+
const absolutePath = join(input.projectRoot, target.path);
|
|
207
|
+
const before = await readTextOrNull(absolutePath);
|
|
208
|
+
const template = await readFile(join(input.harnessRoot, target.templatePath), "utf8");
|
|
209
|
+
const repairMode = this.repairMode(target, before);
|
|
210
|
+
const next = this.merge(target, before, template, input.phasegateVersion);
|
|
211
|
+
const didChange = before !== next;
|
|
212
|
+
const action = this.actionFor(before, didChange, input.force);
|
|
213
|
+
const item: InstallPlanItem = {
|
|
214
|
+
path: target.path,
|
|
215
|
+
action,
|
|
216
|
+
repairMode,
|
|
217
|
+
strategy: target.strategy,
|
|
218
|
+
changed: didChange,
|
|
219
|
+
summary: didChange ? `${target.path}: ${action}` : `${target.path}: already up to date`,
|
|
220
|
+
diff: this.diffSummary(before, next),
|
|
221
|
+
skillHint: repairMode === "ai-assisted" ? SKILL_HINT : null,
|
|
222
|
+
};
|
|
223
|
+
plan.push(item);
|
|
224
|
+
|
|
225
|
+
if (!input.apply || !didChange) continue;
|
|
226
|
+
if ((repairMode === "ai-assisted" || repairMode === "manual") && !input.force) {
|
|
227
|
+
refused.push(item);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (before !== null && (input.force || before.includes(SHELL_BEGIN))) {
|
|
232
|
+
backupDir ??= join(input.projectRoot, ".phasegate", "backups", backupStamp);
|
|
233
|
+
await this.backup(input.projectRoot, target.path, backupDir);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
237
|
+
await writeFile(absolutePath, next, "utf8");
|
|
238
|
+
if (target.executable) await chmod(absolutePath, 0o755);
|
|
239
|
+
changed.push(item);
|
|
240
|
+
|
|
241
|
+
const mode = before === null ? "created" : "merged";
|
|
242
|
+
const hash = this.hashCalculator.compute(next);
|
|
243
|
+
const existingEntry = baseManifest.findEntry(target.path);
|
|
244
|
+
if (existingEntry !== null && existingEntry.hash.equals(hash)) {
|
|
245
|
+
manifest = manifest.addEntry(existingEntry);
|
|
246
|
+
} else {
|
|
247
|
+
manifest = manifest.addEntry(
|
|
248
|
+
DeploymentEntry.create({
|
|
249
|
+
path: target.path,
|
|
250
|
+
mode,
|
|
251
|
+
block: mode === "merged" ? (target.block ?? this.managedBlockFor(target)) : null,
|
|
252
|
+
hash,
|
|
253
|
+
deployedAt: new Date().toISOString(),
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const linkPaths = [
|
|
260
|
+
...(includeClaude ? [".claude/skills"] : []),
|
|
261
|
+
...(includeCodex ? [".codex/skills"] : []),
|
|
262
|
+
];
|
|
263
|
+
for (const linkPath of linkPaths) {
|
|
264
|
+
const item = await this.planSkillLink(input.projectRoot, linkPath);
|
|
265
|
+
plan.push(item);
|
|
266
|
+
if (!input.apply || !item.changed) continue;
|
|
267
|
+
await mkdir(join(input.projectRoot, "skills"), { recursive: true });
|
|
268
|
+
await mkdir(dirname(join(input.projectRoot, linkPath)), { recursive: true });
|
|
269
|
+
await symlink("../skills", join(input.projectRoot, linkPath), process.platform === "win32" ? "junction" : "dir");
|
|
270
|
+
changed.push(item);
|
|
271
|
+
const hash = this.hashCalculator.compute("../skills");
|
|
272
|
+
const existingEntry = baseManifest.findEntry(linkPath);
|
|
273
|
+
if (existingEntry !== null && existingEntry.hash.equals(hash)) {
|
|
274
|
+
manifest = manifest.addEntry(existingEntry);
|
|
275
|
+
} else {
|
|
276
|
+
manifest = manifest.addEntry(
|
|
277
|
+
DeploymentEntry.create({
|
|
278
|
+
path: linkPath,
|
|
279
|
+
mode: "symlink",
|
|
280
|
+
block: null,
|
|
281
|
+
hash,
|
|
282
|
+
deployedAt: new Date().toISOString(),
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (input.apply && changed.length > 0) {
|
|
289
|
+
await this.manifestRepository.save(input.projectRoot, manifest);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { plan, refused, changed, backupDir };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private createTargets(options: {
|
|
296
|
+
readonly includeClaude: boolean;
|
|
297
|
+
readonly includeCodex: boolean;
|
|
298
|
+
readonly includeHusky: boolean;
|
|
299
|
+
readonly includeCi: boolean;
|
|
300
|
+
}): readonly InstallTarget[] {
|
|
301
|
+
return [
|
|
302
|
+
...(options.includeClaude
|
|
303
|
+
? [{ path: ".claude/settings.json", strategy: "json" as const, templatePath: "templates/.claude/settings.json" }]
|
|
304
|
+
: []),
|
|
305
|
+
...(options.includeCodex
|
|
306
|
+
? [{ path: ".codex/hooks.json", strategy: "json" as const, templatePath: "templates/.codex/hooks.json" }]
|
|
307
|
+
: []),
|
|
308
|
+
...(options.includeHusky
|
|
309
|
+
? [
|
|
310
|
+
{
|
|
311
|
+
path: ".husky/pre-commit",
|
|
312
|
+
strategy: "shell" as const,
|
|
313
|
+
templatePath: "docs/templates/hooks/pre-commit",
|
|
314
|
+
executable: true,
|
|
315
|
+
block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-commit managed block" },
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
path: ".husky/commit-msg",
|
|
319
|
+
strategy: "shell" as const,
|
|
320
|
+
templatePath: "docs/templates/hooks/commit-msg",
|
|
321
|
+
executable: true,
|
|
322
|
+
block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate commit-msg managed block" },
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
path: ".husky/pre-push",
|
|
326
|
+
strategy: "shell" as const,
|
|
327
|
+
templatePath: "docs/templates/hooks/pre-push",
|
|
328
|
+
executable: true,
|
|
329
|
+
block: { start: SHELL_BEGIN, end: SHELL_END, content: "phasegate pre-push managed block" },
|
|
330
|
+
},
|
|
331
|
+
]
|
|
332
|
+
: []),
|
|
333
|
+
...(options.includeCi
|
|
334
|
+
? [
|
|
335
|
+
{
|
|
336
|
+
path: ".github/workflows/phasegate-aidlc-gate.yml",
|
|
337
|
+
strategy: "yaml-add" as const,
|
|
338
|
+
templatePath: "docs/templates/ci/aidlc-gate.yml",
|
|
339
|
+
},
|
|
340
|
+
]
|
|
341
|
+
: []),
|
|
342
|
+
{ path: "package.json", strategy: "package-json" as const, templatePath: "package.json" },
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private repairMode(target: InstallTarget, before: string | null): RepairMode {
|
|
347
|
+
if (target.strategy === "shell") return shellRepairMode(before);
|
|
348
|
+
if (target.strategy === "json") return jsonRepairMode(before);
|
|
349
|
+
return "mechanical";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private actionFor(before: string | null, changed: boolean, force: boolean): InstallAction {
|
|
353
|
+
if (!changed) return "will-skip";
|
|
354
|
+
if (before === null) return "missing";
|
|
355
|
+
return force ? "will-overwrite" : "will-merge";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private merge(target: InstallTarget, before: string | null, template: string, version: string): string {
|
|
359
|
+
if (target.strategy === "yaml-add") return before ?? template;
|
|
360
|
+
if (target.strategy === "shell") return mergeShell(before, template);
|
|
361
|
+
if (target.strategy === "package-json") {
|
|
362
|
+
const existing = before === null ? {} : (JSON.parse(before) as unknown);
|
|
363
|
+
const merged = mergePackageJson(isRecord(existing) ? existing : {}, version || PHASEGATE_SCRIPT_VERSION);
|
|
364
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
365
|
+
}
|
|
366
|
+
const existing = before === null ? {} : (JSON.parse(before) as unknown);
|
|
367
|
+
const incoming = JSON.parse(template) as unknown;
|
|
368
|
+
const merged = mergeJsonObject(isRecord(existing) ? existing : {}, isRecord(incoming) ? incoming : {});
|
|
369
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async backup(projectRoot: string, relativePath: string, backupDir: string): Promise<void> {
|
|
373
|
+
const source = join(projectRoot, relativePath);
|
|
374
|
+
const target = join(backupDir, relativePath);
|
|
375
|
+
if (!(await exists(source))) return;
|
|
376
|
+
await mkdir(dirname(target), { recursive: true });
|
|
377
|
+
await copyFile(source, target);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private managedBlockFor(target: InstallTarget): ManagedBlockInput {
|
|
381
|
+
return {
|
|
382
|
+
start: "phasegate structured merge",
|
|
383
|
+
end: "phasegate structured merge",
|
|
384
|
+
content: `${target.strategy}:${target.path}`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private async planSkillLink(projectRoot: string, relativePath: string): Promise<InstallPlanItem> {
|
|
389
|
+
const absolutePath = join(projectRoot, relativePath);
|
|
390
|
+
try {
|
|
391
|
+
const stat = await lstat(absolutePath);
|
|
392
|
+
if (stat.isSymbolicLink() && (await readlink(absolutePath)) === "../skills") {
|
|
393
|
+
return {
|
|
394
|
+
path: relativePath,
|
|
395
|
+
action: "will-skip",
|
|
396
|
+
repairMode: "mechanical",
|
|
397
|
+
strategy: "yaml-add",
|
|
398
|
+
changed: false,
|
|
399
|
+
summary: `${relativePath}: already linked`,
|
|
400
|
+
diff: "no changes",
|
|
401
|
+
skillHint: null,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
path: relativePath,
|
|
406
|
+
action: "will-merge",
|
|
407
|
+
repairMode: "manual",
|
|
408
|
+
strategy: "yaml-add",
|
|
409
|
+
changed: false,
|
|
410
|
+
summary: `${relativePath}: existing non-phasegate path requires manual review`,
|
|
411
|
+
diff: "manual review required",
|
|
412
|
+
skillHint: null,
|
|
413
|
+
};
|
|
414
|
+
} catch {
|
|
415
|
+
return {
|
|
416
|
+
path: relativePath,
|
|
417
|
+
action: "missing",
|
|
418
|
+
repairMode: "mechanical",
|
|
419
|
+
strategy: "yaml-add",
|
|
420
|
+
changed: true,
|
|
421
|
+
summary: `${relativePath}: create symlink`,
|
|
422
|
+
diff: "+ symlink ../skills",
|
|
423
|
+
skillHint: null,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private diffSummary(before: string | null, next: string): string {
|
|
429
|
+
if (before === next) return "no changes";
|
|
430
|
+
if (before === null) return `+ ${next.split(/\r?\n/).filter(Boolean).length} lines`;
|
|
431
|
+
return `~ ${before.length} bytes -> ${next.length} bytes`;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -11,14 +11,29 @@ import { HuskyCommitMsgMissingCheck } from "./application/checks/husky-commit-ms
|
|
|
11
11
|
import { HuskyPreCommitMissingCheck } from "./application/checks/husky-pre-commit-missing-check.js";
|
|
12
12
|
import { HuskyPrePushMissingCheck } from "./application/checks/husky-pre-push-missing-check.js";
|
|
13
13
|
import { PackageJsonDevdepMissingCheck } from "./application/checks/package-json-devdep-missing-check.js";
|
|
14
|
+
import { RunInstallUseCase } from "./application/usecases/run-install.js";
|
|
14
15
|
import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
|
|
16
|
+
import type { MergeStrategy } from "./domain/ports/merge-strategy.js";
|
|
17
|
+
import type { ReconcileStrategy } from "./domain/ports/reconcile-strategy.js";
|
|
18
|
+
import type { UninstallReverseStrategy } from "./domain/ports/uninstall-reverse-strategy.js";
|
|
15
19
|
import { FileSystemManifestRepositoryAdapter } from "./infrastructure/adapters/file-system-manifest-repository-adapter.js";
|
|
20
|
+
import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-hash-adapter.js";
|
|
16
21
|
import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
|
|
17
22
|
import { DoctorHandler } from "./presentation/cli/doctor-handler.js";
|
|
23
|
+
import { InstallHandler } from "./presentation/cli/install-handler.js";
|
|
24
|
+
|
|
25
|
+
type FutureInstallationStrategyPorts = {
|
|
26
|
+
readonly merge?: MergeStrategy<unknown>;
|
|
27
|
+
readonly uninstall?: UninstallReverseStrategy;
|
|
28
|
+
readonly reconcile?: ReconcileStrategy;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const futureInstallationStrategyPorts: FutureInstallationStrategyPorts = {};
|
|
18
32
|
|
|
19
33
|
export function createInstallationModule() {
|
|
20
34
|
const inspector = new NodeFsFileInspectorAdapter();
|
|
21
35
|
const manifestRepository = new FileSystemManifestRepositoryAdapter();
|
|
36
|
+
const hashCalculator = new NodeCryptoHashAdapter();
|
|
22
37
|
const checks = [
|
|
23
38
|
new ClaudeHookMissingCheck(),
|
|
24
39
|
new CodexHookMissingCheck(),
|
|
@@ -31,9 +46,13 @@ export function createInstallationModule() {
|
|
|
31
46
|
new CodexSkillsSymlinkCheck(),
|
|
32
47
|
];
|
|
33
48
|
const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(checks, inspector, manifestRepository);
|
|
49
|
+
const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator);
|
|
34
50
|
return {
|
|
35
51
|
manifestRepository,
|
|
36
52
|
runDoctorDiagnosticsUseCase,
|
|
53
|
+
runInstallUseCase,
|
|
37
54
|
doctorHandler: new DoctorHandler(runDoctorDiagnosticsUseCase),
|
|
55
|
+
installHandler: new InstallHandler(runInstallUseCase),
|
|
56
|
+
futureInstallationStrategyPorts,
|
|
38
57
|
};
|
|
39
58
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-146
|
|
4
|
+
|
|
5
|
+
export interface FileInspector {
|
|
6
|
+
exists(absolutePath: string): Promise<boolean>;
|
|
7
|
+
readText(absolutePath: string): Promise<string | null>;
|
|
8
|
+
readJson<T = unknown>(absolutePath: string): Promise<T | null>;
|
|
9
|
+
readSymlink(absolutePath: string): Promise<string | null>;
|
|
10
|
+
listFiles(absolutePath: string): Promise<string[]>;
|
|
11
|
+
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// @layer domain
|
|
3
3
|
// @work-item-id WI-145
|
|
4
4
|
|
|
5
|
-
import type { FileInspectorPort } from "../../application/ports/file-inspector-port.js";
|
|
6
5
|
import type { CheckId } from "../check-id.js";
|
|
7
6
|
import type { DiagnosticFinding } from "../diagnostic-finding.js";
|
|
7
|
+
import type { FileInspector } from "./file-inspector.js";
|
|
8
8
|
|
|
9
9
|
export interface HeuristicCheck {
|
|
10
10
|
readonly checkId: CheckId;
|
|
11
|
-
run(projectRoot: string, inspector:
|
|
11
|
+
run(projectRoot: string, inspector: FileInspector): Promise<DiagnosticFinding | null>;
|
|
12
12
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer presentation
|
|
3
|
+
// @work-item-id WI-146
|
|
4
|
+
|
|
5
|
+
import type { RunInstallUseCase } from "../../application/usecases/run-install.js";
|
|
6
|
+
|
|
7
|
+
export interface InstallHandlerInput {
|
|
8
|
+
readonly projectRoot: string;
|
|
9
|
+
readonly harnessRoot: string;
|
|
10
|
+
readonly phasegateVersion: string;
|
|
11
|
+
readonly dryRun: boolean;
|
|
12
|
+
readonly apply: boolean;
|
|
13
|
+
readonly force: boolean;
|
|
14
|
+
readonly json: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface InstallHandlerResult {
|
|
18
|
+
readonly stdout: string;
|
|
19
|
+
readonly exitCode: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class InstallHandler {
|
|
23
|
+
constructor(private readonly useCase: RunInstallUseCase) {}
|
|
24
|
+
|
|
25
|
+
async execute(input: InstallHandlerInput): Promise<InstallHandlerResult> {
|
|
26
|
+
const result = await this.useCase.execute(input);
|
|
27
|
+
if (input.json) {
|
|
28
|
+
return {
|
|
29
|
+
stdout: JSON.stringify(result, null, 2),
|
|
30
|
+
exitCode: result.refused.length > 0 ? 1 : 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const lines = [
|
|
34
|
+
input.apply ? "phasegate install apply" : "phasegate install dry-run",
|
|
35
|
+
...result.plan.map((item) => {
|
|
36
|
+
const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
|
|
37
|
+
return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}`;
|
|
38
|
+
}),
|
|
39
|
+
];
|
|
40
|
+
if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
|
|
41
|
+
if (result.refused.length > 0) {
|
|
42
|
+
lines.push("");
|
|
43
|
+
lines.push("Refused ai-assisted/manual targets. Re-run with --force after reviewing the hint.");
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
stdout: lines.join("\n"),
|
|
47
|
+
exitCode: result.refused.length > 0 ? 1 : 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
package/scripts/harness/main.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 起動時に config-foundation で設定を解決し、他Unit に注入する(Cross-unit wiring)。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { access, readFile as fsReadFile, readlink as fsReadlink } from "node:fs/promises";
|
|
11
|
+
import { access, readFile as fsReadFile, readlink as fsReadlink, writeFile as fsWriteFile } from "node:fs/promises";
|
|
12
12
|
import { dirname, join, resolve } from "node:path";
|
|
13
13
|
import { createAdrFoundationModule } from "./adr-foundation/composition-root.js";
|
|
14
14
|
import { createBiomeAstEngineModule } from "./biome-ast-engine/composition-root.js";
|
|
@@ -87,6 +87,60 @@ async function pathExists(path: string): Promise<boolean> {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
interface PackageJsonDocument {
|
|
91
|
+
readonly [key: string]: unknown;
|
|
92
|
+
readonly dependencies?: unknown;
|
|
93
|
+
readonly devDependencies?: unknown;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface PackageDependencyResult {
|
|
97
|
+
readonly created: boolean;
|
|
98
|
+
readonly updated: boolean;
|
|
99
|
+
readonly alreadyPresent: boolean;
|
|
100
|
+
readonly skipped: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
104
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function ensurePhasegatePackageDependency(rootDir: string, version: string): Promise<PackageDependencyResult> {
|
|
108
|
+
const packageJsonPath = join(rootDir, "package.json");
|
|
109
|
+
let pkg: PackageJsonDocument = {};
|
|
110
|
+
let created = false;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const raw = await fsReadFile(packageJsonPath, "utf-8");
|
|
114
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
115
|
+
if (!isJsonRecord(parsed)) {
|
|
116
|
+
return { created: false, updated: false, alreadyPresent: false, skipped: true };
|
|
117
|
+
}
|
|
118
|
+
pkg = parsed;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
121
|
+
return { created: false, updated: false, alreadyPresent: false, skipped: true };
|
|
122
|
+
}
|
|
123
|
+
created = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
(isJsonRecord(pkg.devDependencies) && pkg.devDependencies.phasegate !== undefined) ||
|
|
128
|
+
(isJsonRecord(pkg.dependencies) && pkg.dependencies.phasegate !== undefined)
|
|
129
|
+
) {
|
|
130
|
+
return { created: false, updated: false, alreadyPresent: true, skipped: false };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
pkg = {
|
|
134
|
+
...pkg,
|
|
135
|
+
devDependencies: {
|
|
136
|
+
...(isJsonRecord(pkg.devDependencies) ? pkg.devDependencies : {}),
|
|
137
|
+
phasegate: `^${version}`,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
await fsWriteFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
|
|
141
|
+
return { created, updated: !created, alreadyPresent: false, skipped: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
90
144
|
function printUsage(): void {
|
|
91
145
|
const usage = `
|
|
92
146
|
Usage: phasegate <command> [options]
|
|
@@ -97,7 +151,7 @@ Setup:
|
|
|
97
151
|
--skills <core|all>, --agent <claude|codex|both>, --with-husky, --with-ci, --yes)
|
|
98
152
|
update-skills Re-deploy skills from current harness version
|
|
99
153
|
doctor Diagnose silent installation failures (--json, --strict, --report-out <path>)
|
|
100
|
-
install Install phasegate managed files (
|
|
154
|
+
install Install phasegate managed files (--dry-run|--apply, --force)
|
|
101
155
|
uninstall Uninstall phasegate managed files (stub until WI-147)
|
|
102
156
|
reconcile Reconcile phasegate managed files (stub until WI-148)
|
|
103
157
|
|
|
@@ -317,6 +371,16 @@ Options:
|
|
|
317
371
|
--skills <core|all> Skill set to deploy
|
|
318
372
|
--agent <claude|codex|both> Agent integration target
|
|
319
373
|
--help, -h Show this help`,
|
|
374
|
+
install: `Usage: phasegate install [options]
|
|
375
|
+
|
|
376
|
+
Install phasegate managed files with structured merge.
|
|
377
|
+
|
|
378
|
+
Options:
|
|
379
|
+
--dry-run Preview target actions without writing (default)
|
|
380
|
+
--apply Write merge results and manifest
|
|
381
|
+
--force Force ai-assisted/manual targets after backing up existing files
|
|
382
|
+
--json Output machine-readable JSON
|
|
383
|
+
--help, -h Show this help`,
|
|
320
384
|
validate: `Usage: phasegate validate [options]
|
|
321
385
|
|
|
322
386
|
Run validators against the project. Without --layer, runs all enabled validator layers (L2/L3/L4).
|
|
@@ -741,6 +805,7 @@ async function main(): Promise<void> {
|
|
|
741
805
|
const deployClaude = agent === "claude" || agent === "both";
|
|
742
806
|
const deployCodex = agent === "codex" || agent === "both";
|
|
743
807
|
const result = await deploySkills(harnessRoot, rootDir, skillSet);
|
|
808
|
+
const packageResult = await ensurePhasegatePackageDependency(rootDir, result.version);
|
|
744
809
|
const skillLinkResult = await deployAgentSkillLinks(rootDir, {
|
|
745
810
|
claude: deployClaude,
|
|
746
811
|
codex: deployCodex,
|
|
@@ -770,6 +835,7 @@ async function main(): Promise<void> {
|
|
|
770
835
|
contentForHash: `${result.version}:${skill}`,
|
|
771
836
|
})),
|
|
772
837
|
await createFileManifestRecord(rootDir, join("skills", ".harness-version")),
|
|
838
|
+
packageResult.created || packageResult.updated ? await createFileManifestRecord(rootDir, "package.json") : null,
|
|
773
839
|
configResult.created ? await createFileManifestRecord(rootDir, "phasegate.config.json") : null,
|
|
774
840
|
hooksResult.settingsCreated ? await createFileManifestRecord(rootDir, join(".claude", "settings.json")) : null,
|
|
775
841
|
hooksResult.hookConfigGenerated
|
|
@@ -784,9 +850,39 @@ async function main(): Promise<void> {
|
|
|
784
850
|
huskyPrePushResult?.created ? await createFileManifestRecord(rootDir, join(".husky", "pre-push")) : null,
|
|
785
851
|
...(ciWorkflowResult?.copiedFiles.map((path) => createFileManifestRecord(rootDir, path)) ?? []),
|
|
786
852
|
]);
|
|
853
|
+
const installModule = createInstallationModule();
|
|
854
|
+
const installResult = await installModule.runInstallUseCase.execute({
|
|
855
|
+
projectRoot: rootDir,
|
|
856
|
+
harnessRoot,
|
|
857
|
+
phasegateVersion: result.version,
|
|
858
|
+
dryRun: false,
|
|
859
|
+
apply: true,
|
|
860
|
+
force: false,
|
|
861
|
+
includeClaude: deployClaude,
|
|
862
|
+
includeCodex: deployCodex,
|
|
863
|
+
includeHusky: withHusky,
|
|
864
|
+
includeCi: withCi,
|
|
865
|
+
});
|
|
787
866
|
console.log(
|
|
788
867
|
`✓ Skills deployed to ${result.targetDir} (${result.deployedSkills.length} skills, set: ${skillSet})`,
|
|
789
868
|
);
|
|
869
|
+
if (installResult.changed.length > 0) {
|
|
870
|
+
console.log(`✓ phasegate install structured merge applied (${installResult.changed.length} targets)`);
|
|
871
|
+
}
|
|
872
|
+
if (installResult.refused.length > 0) {
|
|
873
|
+
console.log(
|
|
874
|
+
` phasegate install refused ${installResult.refused.length} ai-assisted/manual targets; run phasegate install --dry-run for details`,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
if (packageResult.created) {
|
|
878
|
+
console.log(`✓ package.json created with phasegate devDependency`);
|
|
879
|
+
} else if (packageResult.updated) {
|
|
880
|
+
console.log(`✓ package.json updated with phasegate devDependency`);
|
|
881
|
+
} else if (packageResult.alreadyPresent) {
|
|
882
|
+
console.log(` package.json already declares phasegate, skipped`);
|
|
883
|
+
} else if (packageResult.skipped) {
|
|
884
|
+
console.log(` package.json could not be updated, skipped`);
|
|
885
|
+
}
|
|
790
886
|
if (configResult.created) {
|
|
791
887
|
console.log(`✓ phasegate.config.json created`);
|
|
792
888
|
} else {
|
|
@@ -947,8 +1043,27 @@ async function main(): Promise<void> {
|
|
|
947
1043
|
}
|
|
948
1044
|
|
|
949
1045
|
case "install": {
|
|
950
|
-
|
|
951
|
-
|
|
1046
|
+
const KNOWN_INSTALL_FLAGS = ["--dry-run", "--apply", "--force", "--json"];
|
|
1047
|
+
const flagError = validateKnownFlags(args, KNOWN_INSTALL_FLAGS);
|
|
1048
|
+
if (flagError) {
|
|
1049
|
+
console.error(flagError);
|
|
1050
|
+
process.exit(2);
|
|
1051
|
+
}
|
|
1052
|
+
const apply = hasFlag(args, "--apply");
|
|
1053
|
+
const dryRun = hasFlag(args, "--dry-run") || !apply;
|
|
1054
|
+
const mod = createInstallationModule();
|
|
1055
|
+
const phasegateVersion = await getHarnessVersion(harnessRoot);
|
|
1056
|
+
const result = await mod.installHandler.execute({
|
|
1057
|
+
projectRoot: rootDir,
|
|
1058
|
+
harnessRoot,
|
|
1059
|
+
phasegateVersion,
|
|
1060
|
+
dryRun,
|
|
1061
|
+
apply,
|
|
1062
|
+
force: hasFlag(args, "--force"),
|
|
1063
|
+
json,
|
|
1064
|
+
});
|
|
1065
|
+
console.log(result.stdout);
|
|
1066
|
+
process.exit(result.exitCode);
|
|
952
1067
|
break;
|
|
953
1068
|
}
|
|
954
1069
|
|