phasegate 0.145.1 → 0.145.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/CHANGELOG.md +16 -0
- package/README.ja.md +20 -1
- package/README.md +20 -1
- package/docs/guide/installation.md +38 -6
- package/package.json +1 -1
- package/scripts/harness/installation/application/ports/file-inspector-port.ts +3 -7
- package/scripts/harness/installation/application/ports/manifest-repository-port.ts +1 -1
- package/scripts/harness/installation/application/usecases/run-install.ts +433 -0
- package/scripts/harness/installation/application/usecases/run-uninstall.ts +365 -0
- package/scripts/harness/installation/composition-root.ts +24 -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/infrastructure/adapters/file-system-manifest-repository-adapter.ts +7 -2
- package/scripts/harness/installation/presentation/cli/install-handler.ts +50 -0
- package/scripts/harness/installation/presentation/cli/uninstall-handler.ts +50 -0
- package/scripts/harness/main.ts +73 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.145.3] - 2026-05-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **WI-147 — manifest-driven uninstall** — `phasegate uninstall --dry-run|--apply [--force] [--json]` を追加し、`.phasegate/manifest.json` に記録された managed files を clean removal できるようにした。
|
|
15
|
+
- `created` / `symlink` entries は削除し、JSON / shell / `package.json` の `merged` entries は PhaseGate managed portion だけを除去して user content を保持する。
|
|
16
|
+
- hash mismatch は `--force` 無しで refuse し、force 時は `.phasegate/backups/uninstall-*/` に snapshot を保存してから削除する。
|
|
17
|
+
- uninstall 完了後、manifest は `.phasegate/uninstalled-*.json` に archive される。
|
|
18
|
+
|
|
19
|
+
## [0.145.2] - 2026-05-11
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **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 できるようにした。
|
|
24
|
+
- 既存カスタム hook は既定では拒否し、`--force` 時は `.phasegate/backups/` に退避してから managed block を適用する。
|
|
25
|
+
|
|
10
26
|
## [0.145.1] - 2026-05-11
|
|
11
27
|
|
|
12
28
|
### Fixed
|
package/README.ja.md
CHANGED
|
@@ -69,7 +69,7 @@ Node.js >= 18, npm >= 9, TypeScript 5.x
|
|
|
69
69
|
# 1. インストール
|
|
70
70
|
npm install --save-dev phasegate
|
|
71
71
|
|
|
72
|
-
# 2.
|
|
72
|
+
# 2. 新規プロジェクトを初期化
|
|
73
73
|
npx phasegate init --name my-project --with-husky --with-ci
|
|
74
74
|
|
|
75
75
|
# 3. AI agent を起動して /product-architect から始める
|
|
@@ -97,6 +97,25 @@ claude
|
|
|
97
97
|
|
|
98
98
|
「設計してから書け」を強制する仕組みなので、設計文書はユーザーがスキル経由で作るのが既定動作です。
|
|
99
99
|
|
|
100
|
+
既存プロジェクトに導入する場合は、構造化 install で差分を確認してから適用します。
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
npx phasegate install --dry-run
|
|
104
|
+
npx phasegate install --apply
|
|
105
|
+
npx phasegate doctor
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`install` は既存の Claude / Codex hooks や Husky script を捨てずに PhaseGate の設定を merge します。書き込み前に変更予定を表示し、package scripts と `phasegate` devDependency、agent skill symlink、未作成の CI workflow、`.phasegate/manifest.json` を整えます。強制的な managed 更新が必要な場合は `npx phasegate install --apply --force` を使います。この場合、置き換え対象は `.phasegate/backups/` に退避されます。
|
|
109
|
+
|
|
110
|
+
後で PhaseGate を外す場合は、manifest ベースの uninstall を使います。
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npx phasegate uninstall --dry-run
|
|
114
|
+
npx phasegate uninstall --apply
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`uninstall` は manifest を読んで、PhaseGate が作成したファイルを削除し、merge した Claude / Codex / Husky / `package.json` から PhaseGate 管理部分だけを取り除きます。ユーザーの既存設定は保持し、manifest は `.phasegate/` 配下に履歴として archive します。
|
|
118
|
+
|
|
100
119
|
### Codex CLI を使う場合
|
|
101
120
|
|
|
102
121
|
```bash
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Node.js >= 18, npm >= 9, TypeScript 5.x
|
|
|
70
70
|
# 1. Install
|
|
71
71
|
npm install --save-dev phasegate
|
|
72
72
|
|
|
73
|
-
# 2. Initialize
|
|
73
|
+
# 2. Initialize a new project
|
|
74
74
|
npx phasegate init --name my-project --with-husky --with-ci
|
|
75
75
|
|
|
76
76
|
# 3. Start your AI agent and begin with product design
|
|
@@ -90,6 +90,25 @@ claude
|
|
|
90
90
|
|
|
91
91
|
`init` intentionally does **not** create `docs/inception/` work item directories or `docs/product/` design documents. Those are produced later by skills such as `/product-architect`, `/domain-designer`, and `/logical-designer`. That is the core contract: no design, no code.
|
|
92
92
|
|
|
93
|
+
For an existing project, preview and apply a structured install instead:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npx phasegate install --dry-run
|
|
97
|
+
npx phasegate install --apply
|
|
98
|
+
npx phasegate doctor
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`install` merges PhaseGate into the current project without discarding existing Claude/Codex hooks or Husky scripts. It reports planned changes before writing, adds package scripts and the `phasegate` devDependency, creates agent skill links, writes the CI workflow when missing, and records managed files in `.phasegate/manifest.json`. If an existing file needs a forced managed update, run `npx phasegate install --apply --force`; PhaseGate backs up replaced files under `.phasegate/backups/`.
|
|
102
|
+
|
|
103
|
+
To remove PhaseGate from that project later, run:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx phasegate uninstall --dry-run
|
|
107
|
+
npx phasegate uninstall --apply
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`uninstall` uses the manifest to delete created files and remove only PhaseGate-managed portions from merged Claude/Codex, Husky, and `package.json` files. User content is preserved, and the manifest is archived under `.phasegate/`.
|
|
111
|
+
|
|
93
112
|
### Codex CLI
|
|
94
113
|
|
|
95
114
|
```bash
|
|
@@ -17,7 +17,7 @@ Or add it directly to your `package.json`:
|
|
|
17
17
|
```json
|
|
18
18
|
{
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"phasegate": "^0.
|
|
20
|
+
"phasegate": "^0.145.3"
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
```
|
|
@@ -30,7 +30,7 @@ npm install
|
|
|
30
30
|
|
|
31
31
|
## Project Setup
|
|
32
32
|
|
|
33
|
-
###
|
|
33
|
+
### New Projects
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
npx phasegate init --name <project-name>
|
|
@@ -44,7 +44,38 @@ For Codex, project initialization stops at the project boundary. After `npx phas
|
|
|
44
44
|
codex features enable codex_hooks
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
###
|
|
47
|
+
### Existing Projects
|
|
48
|
+
|
|
49
|
+
Use `install` when the project already has package scripts, hooks, or CI files that should be preserved.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx phasegate install --dry-run
|
|
53
|
+
npx phasegate install --apply
|
|
54
|
+
npx phasegate doctor
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`install --dry-run` reports whether each target will be created, merged, skipped, or refused. `install --apply` performs the merge, adds package scripts and the `phasegate` devDependency, creates `.claude/skills` and `.codex/skills` links, writes the CI workflow when missing, and records managed entries in `.phasegate/manifest.json`.
|
|
58
|
+
|
|
59
|
+
If a managed update must replace existing custom content, use:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx phasegate install --apply --force
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Forced updates write backups under `.phasegate/backups/<timestamp>/` before applying changes.
|
|
66
|
+
|
|
67
|
+
To remove PhaseGate-managed files later, preview and apply uninstall:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx phasegate uninstall --dry-run
|
|
71
|
+
npx phasegate uninstall --apply
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`uninstall` reads `.phasegate/manifest.json`, deletes files that PhaseGate created, removes only PhaseGate-managed portions from merged JSON, Husky, and `package.json` files, and archives the manifest as `.phasegate/uninstalled-<timestamp>.json`. If a managed file was modified after install, `uninstall --apply` refuses that entry until you rerun with `--force`, which creates a backup under `.phasegate/backups/uninstall-<timestamp>/`.
|
|
75
|
+
|
|
76
|
+
### Manual Setup Pieces
|
|
77
|
+
|
|
78
|
+
If you do not use `init` or `install`, copy the design principle documents manually:
|
|
48
79
|
|
|
49
80
|
```bash
|
|
50
81
|
cp node_modules/phasegate/docs/folder_management_rules.md docs/
|
|
@@ -52,11 +83,11 @@ mkdir -p docs/principles
|
|
|
52
83
|
cp node_modules/phasegate/docs/principles/*.md docs/principles/
|
|
53
84
|
```
|
|
54
85
|
|
|
55
|
-
###
|
|
86
|
+
### Create product overview
|
|
56
87
|
|
|
57
88
|
Create `docs/product/<your_product>_overview.md` as the starting point for AIDLC.
|
|
58
89
|
|
|
59
|
-
###
|
|
90
|
+
### Start Claude Code
|
|
60
91
|
|
|
61
92
|
```bash
|
|
62
93
|
claude # at project root
|
|
@@ -88,5 +119,6 @@ reports/
|
|
|
88
119
|
```bash
|
|
89
120
|
npx phasegate --version
|
|
90
121
|
npx phasegate lint
|
|
91
|
-
|
|
122
|
+
npx phasegate doctor
|
|
123
|
+
npm run phasegate:check-ready
|
|
92
124
|
```
|
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;
|
|
@@ -8,5 +8,5 @@ export interface ManifestRepositoryPort {
|
|
|
8
8
|
load(projectRoot: string): Promise<DeploymentManifest | null>;
|
|
9
9
|
save(projectRoot: string, manifest: DeploymentManifest): Promise<void>;
|
|
10
10
|
exists(projectRoot: string): Promise<boolean>;
|
|
11
|
-
archive(projectRoot: string): Promise<
|
|
11
|
+
archive(projectRoot: string): Promise<string>;
|
|
12
12
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer application
|
|
3
|
+
// @work-item-id WI-147
|
|
4
|
+
|
|
5
|
+
import { access, copyFile, lstat, mkdir, readFile, readlink, rm, rmdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import type { DeploymentEntry } from "../../domain/deployment-entry.js";
|
|
8
|
+
import type { RepairMode } from "../../domain/repair-mode.js";
|
|
9
|
+
import type { HashCalculatorPort } from "../ports/hash-calculator-port.js";
|
|
10
|
+
import type { ManifestRepositoryPort } from "../ports/manifest-repository-port.js";
|
|
11
|
+
|
|
12
|
+
type UninstallAction = "missing-manifest" | "delete" | "unlink" | "reverse-merge" | "skip" | "refuse";
|
|
13
|
+
type StrategyType = "created" | "json" | "shell" | "package-json" | "symlink" | "yaml-add" | "unknown";
|
|
14
|
+
|
|
15
|
+
export interface UninstallPlanItem {
|
|
16
|
+
readonly path: string;
|
|
17
|
+
readonly action: UninstallAction;
|
|
18
|
+
readonly repairMode: RepairMode;
|
|
19
|
+
readonly strategy: StrategyType;
|
|
20
|
+
readonly changed: boolean;
|
|
21
|
+
readonly summary: string;
|
|
22
|
+
readonly diff: string;
|
|
23
|
+
readonly skillHint: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunUninstallInput {
|
|
27
|
+
readonly projectRoot: string;
|
|
28
|
+
readonly harnessRoot: string;
|
|
29
|
+
readonly dryRun: boolean;
|
|
30
|
+
readonly apply: boolean;
|
|
31
|
+
readonly force: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RunUninstallResult {
|
|
35
|
+
readonly plan: readonly UninstallPlanItem[];
|
|
36
|
+
readonly refused: readonly UninstallPlanItem[];
|
|
37
|
+
readonly changed: readonly UninstallPlanItem[];
|
|
38
|
+
readonly backupDir: string | null;
|
|
39
|
+
readonly archivedManifestPath: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SKILL_HINT = "invoke /phasegate-config-doctor";
|
|
43
|
+
const SHELL_BEGIN = "# === phasegate managed (BEGIN) ===";
|
|
44
|
+
const SHELL_END = "# === phasegate managed (END) ===";
|
|
45
|
+
const PHASEGATE_SCRIPT_PREFIX = "phasegate:";
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function exists(path: string): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
await access(path);
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readTextOrNull(path: string): Promise<string | null> {
|
|
61
|
+
try {
|
|
62
|
+
return await readFile(path, "utf8");
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeJsonEntry(value: unknown): string {
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function removeHookEntries(existing: unknown, incoming: unknown): unknown[] {
|
|
73
|
+
const templateEntries = new Set((Array.isArray(incoming) ? incoming : []).map((entry) => normalizeJsonEntry(entry)));
|
|
74
|
+
return (Array.isArray(existing) ? existing : []).filter((entry) => !templateEntries.has(normalizeJsonEntry(entry)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function reverseJsonMerge(currentContent: string, templateContent: string): string {
|
|
78
|
+
const current = JSON.parse(currentContent) as unknown;
|
|
79
|
+
const template = JSON.parse(templateContent) as unknown;
|
|
80
|
+
const result: Record<string, unknown> = isRecord(current) ? { ...current } : {};
|
|
81
|
+
const currentHooks = isRecord(result.hooks) ? result.hooks : {};
|
|
82
|
+
const templateHooks = isRecord(template) && isRecord(template.hooks) ? template.hooks : {};
|
|
83
|
+
const nextHooks: Record<string, unknown> = {};
|
|
84
|
+
for (const [event, entries] of Object.entries(currentHooks)) {
|
|
85
|
+
const remaining = removeHookEntries(entries, templateHooks[event]);
|
|
86
|
+
if (remaining.length > 0) nextHooks[event] = remaining;
|
|
87
|
+
}
|
|
88
|
+
if (Object.keys(nextHooks).length > 0) result.hooks = nextHooks;
|
|
89
|
+
else delete result.hooks;
|
|
90
|
+
|
|
91
|
+
if (isRecord(result.permissions)) {
|
|
92
|
+
const templatePermissions = isRecord(template) && isRecord(template.permissions) ? template.permissions : {};
|
|
93
|
+
const templateDeny = new Set(Array.isArray(templatePermissions.deny) ? templatePermissions.deny : []);
|
|
94
|
+
const currentDeny = Array.isArray(result.permissions.deny) ? result.permissions.deny : [];
|
|
95
|
+
const remainingDeny = currentDeny.filter((entry) => !templateDeny.has(entry));
|
|
96
|
+
const nextPermissions = { ...result.permissions };
|
|
97
|
+
if (remainingDeny.length > 0) nextPermissions.deny = remainingDeny;
|
|
98
|
+
else delete nextPermissions.deny;
|
|
99
|
+
if (Object.keys(nextPermissions).length > 0) result.permissions = nextPermissions;
|
|
100
|
+
else delete result.permissions;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function reverseShellMerge(currentContent: string): string {
|
|
107
|
+
const pattern = new RegExp(`\\n?${escapeRegExp(SHELL_BEGIN)}[\\s\\S]*?${escapeRegExp(SHELL_END)}\\n?`);
|
|
108
|
+
return currentContent.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n").replace(/^\n/, "");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function reversePackageJsonMerge(currentContent: string): string {
|
|
112
|
+
const parsed = JSON.parse(currentContent) as unknown;
|
|
113
|
+
const result = isRecord(parsed) ? { ...parsed } : {};
|
|
114
|
+
if (isRecord(result.devDependencies)) {
|
|
115
|
+
const devDependencies = { ...result.devDependencies };
|
|
116
|
+
delete devDependencies.phasegate;
|
|
117
|
+
if (Object.keys(devDependencies).length > 0) result.devDependencies = devDependencies;
|
|
118
|
+
else delete result.devDependencies;
|
|
119
|
+
}
|
|
120
|
+
if (isRecord(result.scripts)) {
|
|
121
|
+
const scripts = Object.fromEntries(Object.entries(result.scripts).filter(([name]) => !name.startsWith(PHASEGATE_SCRIPT_PREFIX)));
|
|
122
|
+
if (Object.keys(scripts).length > 0) result.scripts = scripts;
|
|
123
|
+
else delete result.scripts;
|
|
124
|
+
}
|
|
125
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function escapeRegExp(value: string): string {
|
|
129
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class RunUninstallUseCase {
|
|
133
|
+
constructor(
|
|
134
|
+
private readonly manifestRepository: ManifestRepositoryPort,
|
|
135
|
+
private readonly hashCalculator: HashCalculatorPort,
|
|
136
|
+
) {}
|
|
137
|
+
|
|
138
|
+
async execute(input: RunUninstallInput): Promise<RunUninstallResult> {
|
|
139
|
+
const manifest = await this.manifestRepository.load(input.projectRoot);
|
|
140
|
+
if (manifest === null) {
|
|
141
|
+
const item: UninstallPlanItem = {
|
|
142
|
+
path: ".phasegate/manifest.json",
|
|
143
|
+
action: "missing-manifest",
|
|
144
|
+
repairMode: "manual",
|
|
145
|
+
strategy: "unknown",
|
|
146
|
+
changed: false,
|
|
147
|
+
summary: "manifest missing; run phasegate doctor and clean up manually",
|
|
148
|
+
diff: "manual cleanup required",
|
|
149
|
+
skillHint: SKILL_HINT,
|
|
150
|
+
};
|
|
151
|
+
return { plan: [item], refused: [], changed: [], backupDir: null, archivedManifestPath: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const plan: UninstallPlanItem[] = [];
|
|
155
|
+
const refused: UninstallPlanItem[] = [];
|
|
156
|
+
const changed: UninstallPlanItem[] = [];
|
|
157
|
+
const backupStamp = `uninstall-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
158
|
+
let backupDir: string | null = null;
|
|
159
|
+
|
|
160
|
+
const outcomes: Array<{
|
|
161
|
+
readonly entry: DeploymentEntry;
|
|
162
|
+
readonly item: UninstallPlanItem;
|
|
163
|
+
readonly needsBackup: boolean;
|
|
164
|
+
readonly apply: () => Promise<void>;
|
|
165
|
+
}> = [];
|
|
166
|
+
for (const entry of manifest.entries) {
|
|
167
|
+
const outcome = await this.planEntry(input, entry);
|
|
168
|
+
outcomes.push({ entry, ...outcome });
|
|
169
|
+
plan.push(outcome.item);
|
|
170
|
+
if (input.apply && outcome.item.changed && (outcome.item.repairMode === "ai-assisted" || outcome.item.repairMode === "manual") && !input.force) {
|
|
171
|
+
refused.push({ ...outcome.item, action: "refuse" });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!input.apply || refused.length > 0) {
|
|
176
|
+
return { plan, refused, changed, backupDir: null, archivedManifestPath: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const outcome of outcomes) {
|
|
180
|
+
if (!outcome.item.changed) continue;
|
|
181
|
+
if (outcome.needsBackup) {
|
|
182
|
+
backupDir ??= join(input.projectRoot, ".phasegate", "backups", backupStamp);
|
|
183
|
+
await this.backup(input.projectRoot, outcome.entry.path, backupDir);
|
|
184
|
+
}
|
|
185
|
+
await outcome.apply();
|
|
186
|
+
await this.cleanupEmptyParents(input.projectRoot, outcome.entry.path);
|
|
187
|
+
changed.push(outcome.item);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const archivedManifestPath = await this.manifestRepository.archive(input.projectRoot);
|
|
191
|
+
return { plan, refused, changed, backupDir, archivedManifestPath };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async planEntry(input: RunUninstallInput, entry: DeploymentEntry): Promise<{
|
|
195
|
+
readonly item: UninstallPlanItem;
|
|
196
|
+
readonly needsBackup: boolean;
|
|
197
|
+
readonly apply: () => Promise<void>;
|
|
198
|
+
}> {
|
|
199
|
+
const absolutePath = this.resolveProjectPath(input.projectRoot, entry.path);
|
|
200
|
+
const currentContent = await readTextOrNull(absolutePath);
|
|
201
|
+
const strategy = this.strategyFor(entry.path, entry.mode);
|
|
202
|
+
if (currentContent === null && strategy !== "symlink") {
|
|
203
|
+
return {
|
|
204
|
+
item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: already absent`, "no changes", null),
|
|
205
|
+
needsBackup: false,
|
|
206
|
+
apply: async () => {},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (entry.mode === "symlink") return this.planSymlink(input.projectRoot, entry);
|
|
211
|
+
if (entry.mode === "created") return this.planCreated(input.projectRoot, entry, currentContent ?? "");
|
|
212
|
+
return this.planMerged(input, entry, currentContent ?? "", strategy);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async planSymlink(projectRoot: string, entry: DeploymentEntry) {
|
|
216
|
+
const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
|
|
217
|
+
let target: string | null = null;
|
|
218
|
+
try {
|
|
219
|
+
const stat = await lstat(absolutePath);
|
|
220
|
+
target = stat.isSymbolicLink() ? await readlink(absolutePath) : null;
|
|
221
|
+
} catch {
|
|
222
|
+
target = null;
|
|
223
|
+
}
|
|
224
|
+
if (target === "../skills") {
|
|
225
|
+
return {
|
|
226
|
+
item: this.item(entry.path, "unlink", "mechanical", "symlink", true, `${entry.path}: remove symlink`, "- symlink ../skills", null),
|
|
227
|
+
needsBackup: false,
|
|
228
|
+
apply: async () => {
|
|
229
|
+
await rm(absolutePath, { force: true });
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
item: this.item(entry.path, "skip", "manual", "symlink", false, `${entry.path}: non-phasegate symlink/path skipped`, "manual review required", null),
|
|
235
|
+
needsBackup: false,
|
|
236
|
+
apply: async () => {},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async planCreated(projectRoot: string, entry: DeploymentEntry, currentContent: string) {
|
|
241
|
+
const absolutePath = this.resolveProjectPath(projectRoot, entry.path);
|
|
242
|
+
const currentHash = this.hashCalculator.compute(currentContent);
|
|
243
|
+
const matchesManifest = currentHash.equals(entry.hash);
|
|
244
|
+
const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
|
|
245
|
+
return {
|
|
246
|
+
item: this.item(
|
|
247
|
+
entry.path,
|
|
248
|
+
"delete",
|
|
249
|
+
repairMode,
|
|
250
|
+
this.strategyFor(entry.path, entry.mode),
|
|
251
|
+
true,
|
|
252
|
+
matchesManifest ? `${entry.path}: delete created file` : `${entry.path}: hash mismatch; force required`,
|
|
253
|
+
"- file",
|
|
254
|
+
matchesManifest ? null : SKILL_HINT,
|
|
255
|
+
),
|
|
256
|
+
needsBackup: !matchesManifest,
|
|
257
|
+
apply: async () => {
|
|
258
|
+
await rm(absolutePath, { force: true });
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async planMerged(input: RunUninstallInput, entry: DeploymentEntry, currentContent: string, strategy: StrategyType) {
|
|
264
|
+
const currentHash = this.hashCalculator.compute(currentContent);
|
|
265
|
+
const matchesManifest = currentHash.equals(entry.hash);
|
|
266
|
+
try {
|
|
267
|
+
const next = await this.reverseMerged(input.harnessRoot, entry.path, currentContent, strategy);
|
|
268
|
+
if (next === currentContent) {
|
|
269
|
+
return {
|
|
270
|
+
item: this.item(entry.path, "skip", "mechanical", strategy, false, `${entry.path}: managed portion already absent`, "no changes", null),
|
|
271
|
+
needsBackup: false,
|
|
272
|
+
apply: async () => {},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const repairMode: RepairMode = matchesManifest ? "mechanical" : "ai-assisted";
|
|
276
|
+
return {
|
|
277
|
+
item: this.item(
|
|
278
|
+
entry.path,
|
|
279
|
+
"reverse-merge",
|
|
280
|
+
repairMode,
|
|
281
|
+
strategy,
|
|
282
|
+
true,
|
|
283
|
+
matchesManifest ? `${entry.path}: remove managed portion` : `${entry.path}: hash mismatch; force required`,
|
|
284
|
+
`~ ${currentContent.length} bytes -> ${next.length} bytes`,
|
|
285
|
+
matchesManifest ? null : SKILL_HINT,
|
|
286
|
+
),
|
|
287
|
+
needsBackup: !matchesManifest,
|
|
288
|
+
apply: async () => {
|
|
289
|
+
await writeFile(this.resolveProjectPath(input.projectRoot, entry.path), next, "utf8");
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
} catch {
|
|
293
|
+
return {
|
|
294
|
+
item: this.item(entry.path, "skip", "manual", strategy, false, `${entry.path}: reverse merge requires manual review`, "manual review required", SKILL_HINT),
|
|
295
|
+
needsBackup: false,
|
|
296
|
+
apply: async () => {},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async reverseMerged(harnessRoot: string, path: string, currentContent: string, strategy: StrategyType): Promise<string> {
|
|
302
|
+
if (strategy === "shell") return currentContent.includes(SHELL_BEGIN) ? reverseShellMerge(currentContent) : currentContent;
|
|
303
|
+
if (strategy === "package-json") return reversePackageJsonMerge(currentContent);
|
|
304
|
+
if (strategy === "json") return reverseJsonMerge(currentContent, await readFile(join(harnessRoot, this.templateFor(path)), "utf8"));
|
|
305
|
+
throw new Error(`Unsupported merged strategy: ${strategy}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private strategyFor(path: string, mode: string): StrategyType {
|
|
309
|
+
if (mode === "symlink") return "symlink";
|
|
310
|
+
if (mode === "created") return path.endsWith(".yml") || path.endsWith(".yaml") ? "yaml-add" : "created";
|
|
311
|
+
if (path === "package.json") return "package-json";
|
|
312
|
+
if (path.endsWith(".json")) return "json";
|
|
313
|
+
if (path.startsWith(".husky/")) return "shell";
|
|
314
|
+
return "unknown";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private templateFor(path: string): string {
|
|
318
|
+
if (path === ".claude/settings.json") return "templates/.claude/settings.json";
|
|
319
|
+
if (path === ".codex/hooks.json") return "templates/.codex/hooks.json";
|
|
320
|
+
throw new Error(`No template for ${path}`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async backup(projectRoot: string, relativePath: string, backupDir: string): Promise<void> {
|
|
324
|
+
const source = this.resolveProjectPath(projectRoot, relativePath);
|
|
325
|
+
if (!(await exists(source))) return;
|
|
326
|
+
const target = join(backupDir, relativePath);
|
|
327
|
+
await mkdir(dirname(target), { recursive: true });
|
|
328
|
+
await copyFile(source, target);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async cleanupEmptyParents(projectRoot: string, relativePath: string): Promise<void> {
|
|
332
|
+
let current = dirname(this.resolveProjectPath(projectRoot, relativePath));
|
|
333
|
+
const root = resolve(projectRoot);
|
|
334
|
+
while (current.startsWith(root) && current !== root && relative(root, current) !== ".phasegate") {
|
|
335
|
+
try {
|
|
336
|
+
await rmdir(current);
|
|
337
|
+
} catch {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
current = dirname(current);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private resolveProjectPath(projectRoot: string, relativePath: string): string {
|
|
345
|
+
const absolutePath = resolve(projectRoot, relativePath);
|
|
346
|
+
const root = resolve(projectRoot);
|
|
347
|
+
if (absolutePath !== root && !absolutePath.startsWith(`${root}/`)) {
|
|
348
|
+
throw new Error(`Manifest entry escapes project root: ${relativePath}`);
|
|
349
|
+
}
|
|
350
|
+
return absolutePath;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private item(
|
|
354
|
+
path: string,
|
|
355
|
+
action: UninstallAction,
|
|
356
|
+
repairMode: RepairMode,
|
|
357
|
+
strategy: StrategyType,
|
|
358
|
+
changed: boolean,
|
|
359
|
+
summary: string,
|
|
360
|
+
diff: string,
|
|
361
|
+
skillHint: string | null,
|
|
362
|
+
): UninstallPlanItem {
|
|
363
|
+
return { path, action, repairMode, strategy, changed, summary, diff, skillHint };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -11,14 +11,31 @@ 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";
|
|
15
|
+
import { RunUninstallUseCase } from "./application/usecases/run-uninstall.js";
|
|
14
16
|
import { RunDoctorDiagnosticsUseCase } from "./application/usecases/run-doctor-diagnostics.js";
|
|
17
|
+
import type { MergeStrategy } from "./domain/ports/merge-strategy.js";
|
|
18
|
+
import type { ReconcileStrategy } from "./domain/ports/reconcile-strategy.js";
|
|
19
|
+
import type { UninstallReverseStrategy } from "./domain/ports/uninstall-reverse-strategy.js";
|
|
15
20
|
import { FileSystemManifestRepositoryAdapter } from "./infrastructure/adapters/file-system-manifest-repository-adapter.js";
|
|
21
|
+
import { NodeCryptoHashAdapter } from "./infrastructure/adapters/node-crypto-hash-adapter.js";
|
|
16
22
|
import { NodeFsFileInspectorAdapter } from "./infrastructure/adapters/node-fs-file-inspector-adapter.js";
|
|
17
23
|
import { DoctorHandler } from "./presentation/cli/doctor-handler.js";
|
|
24
|
+
import { InstallHandler } from "./presentation/cli/install-handler.js";
|
|
25
|
+
import { UninstallHandler } from "./presentation/cli/uninstall-handler.js";
|
|
26
|
+
|
|
27
|
+
type FutureInstallationStrategyPorts = {
|
|
28
|
+
readonly merge?: MergeStrategy<unknown>;
|
|
29
|
+
readonly uninstall?: UninstallReverseStrategy;
|
|
30
|
+
readonly reconcile?: ReconcileStrategy;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const futureInstallationStrategyPorts: FutureInstallationStrategyPorts = {};
|
|
18
34
|
|
|
19
35
|
export function createInstallationModule() {
|
|
20
36
|
const inspector = new NodeFsFileInspectorAdapter();
|
|
21
37
|
const manifestRepository = new FileSystemManifestRepositoryAdapter();
|
|
38
|
+
const hashCalculator = new NodeCryptoHashAdapter();
|
|
22
39
|
const checks = [
|
|
23
40
|
new ClaudeHookMissingCheck(),
|
|
24
41
|
new CodexHookMissingCheck(),
|
|
@@ -31,9 +48,16 @@ export function createInstallationModule() {
|
|
|
31
48
|
new CodexSkillsSymlinkCheck(),
|
|
32
49
|
];
|
|
33
50
|
const runDoctorDiagnosticsUseCase = new RunDoctorDiagnosticsUseCase(checks, inspector, manifestRepository);
|
|
51
|
+
const runInstallUseCase = new RunInstallUseCase(manifestRepository, hashCalculator);
|
|
52
|
+
const runUninstallUseCase = new RunUninstallUseCase(manifestRepository, hashCalculator);
|
|
34
53
|
return {
|
|
35
54
|
manifestRepository,
|
|
36
55
|
runDoctorDiagnosticsUseCase,
|
|
56
|
+
runInstallUseCase,
|
|
57
|
+
runUninstallUseCase,
|
|
37
58
|
doctorHandler: new DoctorHandler(runDoctorDiagnosticsUseCase),
|
|
59
|
+
installHandler: new InstallHandler(runInstallUseCase),
|
|
60
|
+
uninstallHandler: new UninstallHandler(runUninstallUseCase),
|
|
61
|
+
futureInstallationStrategyPorts,
|
|
38
62
|
};
|
|
39
63
|
}
|
|
@@ -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
|
}
|
|
@@ -52,8 +52,13 @@ export class FileSystemManifestRepositoryAdapter implements ManifestRepositoryPo
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
async archive(
|
|
56
|
-
|
|
55
|
+
async archive(projectRoot: string): Promise<string> {
|
|
56
|
+
const phasegateDir = join(projectRoot, ".phasegate");
|
|
57
|
+
await mkdir(phasegateDir, { recursive: true });
|
|
58
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
59
|
+
const archivedPath = join(phasegateDir, `uninstalled-${stamp}.json`);
|
|
60
|
+
await rename(this.manifestPath(projectRoot), archivedPath);
|
|
61
|
+
return archivedPath;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
private manifestPath(projectRoot: string): string {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer presentation
|
|
3
|
+
// @work-item-id WI-147
|
|
4
|
+
|
|
5
|
+
import type { RunUninstallUseCase } from "../../application/usecases/run-uninstall.js";
|
|
6
|
+
|
|
7
|
+
export interface UninstallHandlerInput {
|
|
8
|
+
readonly projectRoot: string;
|
|
9
|
+
readonly harnessRoot: string;
|
|
10
|
+
readonly dryRun: boolean;
|
|
11
|
+
readonly apply: boolean;
|
|
12
|
+
readonly force: boolean;
|
|
13
|
+
readonly json: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UninstallHandlerResult {
|
|
17
|
+
readonly stdout: string;
|
|
18
|
+
readonly exitCode: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class UninstallHandler {
|
|
22
|
+
constructor(private readonly useCase: RunUninstallUseCase) {}
|
|
23
|
+
|
|
24
|
+
async execute(input: UninstallHandlerInput): Promise<UninstallHandlerResult> {
|
|
25
|
+
const result = await this.useCase.execute(input);
|
|
26
|
+
if (input.json) {
|
|
27
|
+
return {
|
|
28
|
+
stdout: JSON.stringify(result, null, 2),
|
|
29
|
+
exitCode: result.refused.length > 0 ? 1 : 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const lines = [
|
|
33
|
+
input.apply ? "phasegate uninstall apply" : "phasegate uninstall dry-run",
|
|
34
|
+
...result.plan.map((item) => {
|
|
35
|
+
const hint = item.skillHint ? `; hint: ${item.skillHint}` : "";
|
|
36
|
+
return `- ${item.path}: ${item.action} (${item.repairMode}, ${item.strategy}); diff: ${item.diff}${hint}`;
|
|
37
|
+
}),
|
|
38
|
+
];
|
|
39
|
+
if (result.backupDir !== null) lines.push(`backups: ${result.backupDir}`);
|
|
40
|
+
if (result.archivedManifestPath !== null) lines.push(`archived manifest: ${result.archivedManifestPath}`);
|
|
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
|
@@ -151,8 +151,8 @@ Setup:
|
|
|
151
151
|
--skills <core|all>, --agent <claude|codex|both>, --with-husky, --with-ci, --yes)
|
|
152
152
|
update-skills Re-deploy skills from current harness version
|
|
153
153
|
doctor Diagnose silent installation failures (--json, --strict, --report-out <path>)
|
|
154
|
-
install Install phasegate managed files (
|
|
155
|
-
uninstall Uninstall phasegate managed files (
|
|
154
|
+
install Install phasegate managed files (--dry-run|--apply, --force)
|
|
155
|
+
uninstall Uninstall phasegate managed files (--dry-run|--apply, --force)
|
|
156
156
|
reconcile Reconcile phasegate managed files (stub until WI-148)
|
|
157
157
|
|
|
158
158
|
Commands:
|
|
@@ -371,6 +371,16 @@ Options:
|
|
|
371
371
|
--skills <core|all> Skill set to deploy
|
|
372
372
|
--agent <claude|codex|both> Agent integration target
|
|
373
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`,
|
|
374
384
|
validate: `Usage: phasegate validate [options]
|
|
375
385
|
|
|
376
386
|
Run validators against the project. Without --layer, runs all enabled validator layers (L2/L3/L4).
|
|
@@ -840,9 +850,30 @@ async function main(): Promise<void> {
|
|
|
840
850
|
huskyPrePushResult?.created ? await createFileManifestRecord(rootDir, join(".husky", "pre-push")) : null,
|
|
841
851
|
...(ciWorkflowResult?.copiedFiles.map((path) => createFileManifestRecord(rootDir, path)) ?? []),
|
|
842
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
|
+
});
|
|
843
866
|
console.log(
|
|
844
867
|
`✓ Skills deployed to ${result.targetDir} (${result.deployedSkills.length} skills, set: ${skillSet})`,
|
|
845
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
|
+
}
|
|
846
877
|
if (packageResult.created) {
|
|
847
878
|
console.log(`✓ package.json created with phasegate devDependency`);
|
|
848
879
|
} else if (packageResult.updated) {
|
|
@@ -1012,14 +1043,50 @@ async function main(): Promise<void> {
|
|
|
1012
1043
|
}
|
|
1013
1044
|
|
|
1014
1045
|
case "install": {
|
|
1015
|
-
|
|
1016
|
-
|
|
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);
|
|
1017
1067
|
break;
|
|
1018
1068
|
}
|
|
1019
1069
|
|
|
1020
1070
|
case "uninstall": {
|
|
1021
|
-
|
|
1022
|
-
|
|
1071
|
+
const KNOWN_UNINSTALL_FLAGS = ["--dry-run", "--apply", "--force", "--json"];
|
|
1072
|
+
const flagError = validateKnownFlags(args, KNOWN_UNINSTALL_FLAGS);
|
|
1073
|
+
if (flagError) {
|
|
1074
|
+
console.error(flagError);
|
|
1075
|
+
process.exit(2);
|
|
1076
|
+
}
|
|
1077
|
+
const apply = hasFlag(args, "--apply");
|
|
1078
|
+
const dryRun = hasFlag(args, "--dry-run") || !apply;
|
|
1079
|
+
const mod = createInstallationModule();
|
|
1080
|
+
const result = await mod.uninstallHandler.execute({
|
|
1081
|
+
projectRoot: rootDir,
|
|
1082
|
+
harnessRoot,
|
|
1083
|
+
dryRun,
|
|
1084
|
+
apply,
|
|
1085
|
+
force: hasFlag(args, "--force"),
|
|
1086
|
+
json,
|
|
1087
|
+
});
|
|
1088
|
+
console.log(result.stdout);
|
|
1089
|
+
process.exit(result.exitCode);
|
|
1023
1090
|
break;
|
|
1024
1091
|
}
|
|
1025
1092
|
|