phasegate 0.144.0 → 0.145.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/docs/guide/cli-reference.md +1 -1
- package/package.json +1 -1
- package/scripts/harness/installation/application/checks/check-utils.ts +65 -0
- package/scripts/harness/installation/application/checks/ci-workflow-missing-check.ts +29 -0
- package/scripts/harness/installation/application/checks/claude-hook-missing-check.ts +45 -0
- package/scripts/harness/installation/application/checks/claude-skills-symlink-check.ts +25 -0
- package/scripts/harness/installation/application/checks/codex-hook-missing-check.ts +44 -0
- package/scripts/harness/installation/application/checks/codex-skills-symlink-check.ts +25 -0
- package/scripts/harness/installation/application/checks/husky-commit-msg-missing-check.ts +25 -0
- package/scripts/harness/installation/application/checks/husky-pre-commit-missing-check.ts +37 -0
- package/scripts/harness/installation/application/checks/husky-pre-push-missing-check.ts +25 -0
- package/scripts/harness/installation/application/checks/package-json-devdep-missing-check.ts +37 -0
- package/scripts/harness/installation/application/ports/file-inspector-port.ts +11 -0
- package/scripts/harness/installation/application/ports/hash-calculator-port.ts +9 -0
- package/scripts/harness/installation/application/ports/manifest-repository-port.ts +12 -0
- package/scripts/harness/installation/application/usecases/run-doctor-diagnostics.ts +44 -0
- package/scripts/harness/installation/application/wrappers/skill-deployer-manifest-builder.ts +38 -0
- package/scripts/harness/installation/composition-root.ts +39 -0
- package/scripts/harness/installation/domain/check-id.ts +21 -0
- package/scripts/harness/installation/domain/deployment-entry.ts +86 -0
- package/scripts/harness/installation/domain/deployment-manifest.ts +90 -0
- package/scripts/harness/installation/domain/diagnostic-finding.ts +71 -0
- package/scripts/harness/installation/domain/diagnostic-report.ts +52 -0
- package/scripts/harness/installation/domain/hash.ts +30 -0
- package/scripts/harness/installation/domain/managed-block.ts +37 -0
- package/scripts/harness/installation/domain/ports/heuristic-check.ts +12 -0
- package/scripts/harness/installation/domain/ports/merge-strategy.ts +13 -0
- package/scripts/harness/installation/domain/ports/reconcile-strategy.ts +15 -0
- package/scripts/harness/installation/domain/ports/uninstall-reverse-strategy.ts +10 -0
- package/scripts/harness/installation/domain/repair-mode.ts +11 -0
- package/scripts/harness/installation/domain/repair-table.ts +41 -0
- package/scripts/harness/installation/domain/suggested-skill.ts +37 -0
- package/scripts/harness/installation/infrastructure/adapters/file-system-manifest-repository-adapter.ts +62 -0
- package/scripts/harness/installation/infrastructure/adapters/node-crypto-hash-adapter.ts +13 -0
- package/scripts/harness/installation/infrastructure/adapters/node-fs-file-inspector-adapter.ts +53 -0
- package/scripts/harness/installation/presentation/cli/doctor-handler.ts +51 -0
- package/scripts/harness/installation/presentation/formatters/diagnostic-report-formatter.ts +53 -0
- package/scripts/harness/main.ts +113 -7
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import { Hash } from "./hash.js";
|
|
6
|
+
import { ManagedBlock, type ManagedBlockInput } from "./managed-block.js";
|
|
7
|
+
|
|
8
|
+
export type DeploymentEntryMode = "created" | "merged" | "symlink";
|
|
9
|
+
|
|
10
|
+
export interface DeploymentEntryInput {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly mode: DeploymentEntryMode;
|
|
13
|
+
readonly block: ManagedBlock | ManagedBlockInput | null;
|
|
14
|
+
readonly hash: Hash | string;
|
|
15
|
+
readonly deployedAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DeploymentEntryJson {
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly mode: DeploymentEntryMode;
|
|
21
|
+
readonly block?: ManagedBlockInput | null;
|
|
22
|
+
readonly hash: string;
|
|
23
|
+
readonly deployedAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class DeploymentEntry {
|
|
27
|
+
readonly path: string;
|
|
28
|
+
readonly mode: DeploymentEntryMode;
|
|
29
|
+
readonly block: ManagedBlock | null;
|
|
30
|
+
readonly hash: Hash;
|
|
31
|
+
readonly deployedAt: string;
|
|
32
|
+
|
|
33
|
+
private constructor(input: DeploymentEntryInput) {
|
|
34
|
+
if (input.path.trim().length === 0 || input.path.startsWith("/")) {
|
|
35
|
+
throw new Error("DeploymentEntry path must be a project-relative path");
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isFinite(Date.parse(input.deployedAt))) {
|
|
38
|
+
throw new Error("DeploymentEntry deployedAt must be ISO8601-compatible");
|
|
39
|
+
}
|
|
40
|
+
const block = input.block instanceof ManagedBlock
|
|
41
|
+
? input.block
|
|
42
|
+
: input.block === null
|
|
43
|
+
? null
|
|
44
|
+
: ManagedBlock.create(input.block);
|
|
45
|
+
if (input.mode === "merged" && block === null) {
|
|
46
|
+
throw new Error("DeploymentEntry with mode merged requires a managed block");
|
|
47
|
+
}
|
|
48
|
+
if (input.mode !== "merged" && block !== null) {
|
|
49
|
+
throw new Error("DeploymentEntry block is only allowed for merged mode");
|
|
50
|
+
}
|
|
51
|
+
this.path = input.path;
|
|
52
|
+
this.mode = input.mode;
|
|
53
|
+
this.block = block;
|
|
54
|
+
this.hash = input.hash instanceof Hash ? input.hash : Hash.from(input.hash);
|
|
55
|
+
this.deployedAt = input.deployedAt;
|
|
56
|
+
Object.freeze(this);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static create(input: DeploymentEntryInput): DeploymentEntry {
|
|
60
|
+
return new DeploymentEntry(input);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static fromJSON(input: DeploymentEntryJson): DeploymentEntry {
|
|
64
|
+
return DeploymentEntry.create({
|
|
65
|
+
path: input.path,
|
|
66
|
+
mode: input.mode,
|
|
67
|
+
block: input.block ?? null,
|
|
68
|
+
hash: input.hash,
|
|
69
|
+
deployedAt: input.deployedAt,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
equals(other: DeploymentEntry): boolean {
|
|
74
|
+
return this.path === other.path;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
toJSON(): DeploymentEntryJson {
|
|
78
|
+
return {
|
|
79
|
+
path: this.path,
|
|
80
|
+
mode: this.mode,
|
|
81
|
+
block: this.block?.toJSON() ?? null,
|
|
82
|
+
hash: this.hash.toString(),
|
|
83
|
+
deployedAt: this.deployedAt,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import { DeploymentEntry, type DeploymentEntryJson } from "./deployment-entry.js";
|
|
6
|
+
|
|
7
|
+
export interface DeploymentManifestInput {
|
|
8
|
+
readonly version: string;
|
|
9
|
+
readonly installedAt: string;
|
|
10
|
+
readonly entries: readonly DeploymentEntry[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DeploymentManifestJson {
|
|
14
|
+
readonly version: string;
|
|
15
|
+
readonly installedAt: string;
|
|
16
|
+
readonly entries: readonly DeploymentEntryJson[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class DeploymentManifest {
|
|
20
|
+
private static readonly SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
21
|
+
|
|
22
|
+
readonly version: string;
|
|
23
|
+
readonly installedAt: string;
|
|
24
|
+
readonly entries: readonly DeploymentEntry[];
|
|
25
|
+
|
|
26
|
+
private constructor(input: DeploymentManifestInput) {
|
|
27
|
+
if (!DeploymentManifest.SEMVER_PATTERN.test(input.version)) {
|
|
28
|
+
throw new Error("DeploymentManifest version must be semver");
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isFinite(Date.parse(input.installedAt))) {
|
|
31
|
+
throw new Error("DeploymentManifest installedAt must be ISO8601-compatible");
|
|
32
|
+
}
|
|
33
|
+
const paths = new Set<string>();
|
|
34
|
+
for (const entry of input.entries) {
|
|
35
|
+
if (paths.has(entry.path)) {
|
|
36
|
+
throw new Error(`DeploymentManifest contains duplicate entry path: ${entry.path}`);
|
|
37
|
+
}
|
|
38
|
+
paths.add(entry.path);
|
|
39
|
+
}
|
|
40
|
+
this.version = input.version;
|
|
41
|
+
this.installedAt = input.installedAt;
|
|
42
|
+
this.entries = Object.freeze([...input.entries]);
|
|
43
|
+
Object.freeze(this);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static create(version: string, installedAt = new Date().toISOString()): DeploymentManifest {
|
|
47
|
+
return new DeploymentManifest({ version, installedAt, entries: [] });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static reconstitute(input: DeploymentManifestInput): DeploymentManifest {
|
|
51
|
+
return new DeploymentManifest(input);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static fromJSON(input: DeploymentManifestJson): DeploymentManifest {
|
|
55
|
+
return DeploymentManifest.reconstitute({
|
|
56
|
+
version: input.version,
|
|
57
|
+
installedAt: input.installedAt,
|
|
58
|
+
entries: input.entries.map((entry) => DeploymentEntry.fromJSON(entry)),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
addEntry(entry: DeploymentEntry): DeploymentManifest {
|
|
63
|
+
const entries = this.entries.filter((current) => current.path !== entry.path);
|
|
64
|
+
return DeploymentManifest.reconstitute({
|
|
65
|
+
version: this.version,
|
|
66
|
+
installedAt: this.installedAt,
|
|
67
|
+
entries: [...entries, entry],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
removeEntry(path: string): DeploymentManifest {
|
|
72
|
+
return DeploymentManifest.reconstitute({
|
|
73
|
+
version: this.version,
|
|
74
|
+
installedAt: this.installedAt,
|
|
75
|
+
entries: this.entries.filter((entry) => entry.path !== path),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
findEntry(path: string): DeploymentEntry | null {
|
|
80
|
+
return this.entries.find((entry) => entry.path === path) ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
toJSON(): DeploymentManifestJson {
|
|
84
|
+
return {
|
|
85
|
+
version: this.version,
|
|
86
|
+
installedAt: this.installedAt,
|
|
87
|
+
entries: this.entries.map((entry) => entry.toJSON()),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import type { CheckId } from "./check-id.js";
|
|
6
|
+
import type { RepairMode } from "./repair-mode.js";
|
|
7
|
+
import { SuggestedSkill, type SuggestedSkillInput } from "./suggested-skill.js";
|
|
8
|
+
|
|
9
|
+
export type DiagnosticSeverity = "red" | "warn";
|
|
10
|
+
|
|
11
|
+
export interface DiagnosticFindingInput {
|
|
12
|
+
readonly checkId: CheckId;
|
|
13
|
+
readonly severity: DiagnosticSeverity;
|
|
14
|
+
readonly target: string;
|
|
15
|
+
readonly message: string;
|
|
16
|
+
readonly repairMode: RepairMode;
|
|
17
|
+
readonly repairHint: string | null;
|
|
18
|
+
readonly suggestedSkill: SuggestedSkill | SuggestedSkillInput | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiagnosticFindingJson extends Omit<DiagnosticFindingInput, "suggestedSkill"> {
|
|
22
|
+
readonly suggestedSkill: SuggestedSkillInput | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class DiagnosticFinding {
|
|
26
|
+
readonly checkId: CheckId;
|
|
27
|
+
readonly severity: DiagnosticSeverity;
|
|
28
|
+
readonly target: string;
|
|
29
|
+
readonly message: string;
|
|
30
|
+
readonly repairMode: RepairMode;
|
|
31
|
+
readonly repairHint: string | null;
|
|
32
|
+
readonly suggestedSkill: SuggestedSkill | null;
|
|
33
|
+
|
|
34
|
+
private constructor(input: DiagnosticFindingInput) {
|
|
35
|
+
const suggestedSkill = input.suggestedSkill instanceof SuggestedSkill
|
|
36
|
+
? input.suggestedSkill
|
|
37
|
+
: input.suggestedSkill === null
|
|
38
|
+
? null
|
|
39
|
+
: SuggestedSkill.create(input.suggestedSkill);
|
|
40
|
+
if (input.repairMode === "ai-assisted" && suggestedSkill === null) {
|
|
41
|
+
throw new Error("DiagnosticFinding requires suggestedSkill for ai-assisted repairMode");
|
|
42
|
+
}
|
|
43
|
+
if (input.target.trim().length === 0 || input.message.trim().length === 0) {
|
|
44
|
+
throw new Error("DiagnosticFinding target and message are required");
|
|
45
|
+
}
|
|
46
|
+
this.checkId = input.checkId;
|
|
47
|
+
this.severity = input.severity;
|
|
48
|
+
this.target = input.target;
|
|
49
|
+
this.message = input.message;
|
|
50
|
+
this.repairMode = input.repairMode;
|
|
51
|
+
this.repairHint = input.repairHint;
|
|
52
|
+
this.suggestedSkill = suggestedSkill;
|
|
53
|
+
Object.freeze(this);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static create(input: DiagnosticFindingInput): DiagnosticFinding {
|
|
57
|
+
return new DiagnosticFinding(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toJSON(): DiagnosticFindingJson {
|
|
61
|
+
return {
|
|
62
|
+
checkId: this.checkId,
|
|
63
|
+
severity: this.severity,
|
|
64
|
+
target: this.target,
|
|
65
|
+
message: this.message,
|
|
66
|
+
repairMode: this.repairMode,
|
|
67
|
+
repairHint: this.repairHint,
|
|
68
|
+
suggestedSkill: this.suggestedSkill?.toJSON() ?? null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import { DiagnosticFinding } from "./diagnostic-finding.js";
|
|
6
|
+
|
|
7
|
+
export type DiagnosticOverallStatus = "green" | "warn" | "red";
|
|
8
|
+
|
|
9
|
+
export class DiagnosticReport {
|
|
10
|
+
readonly findings: readonly DiagnosticFinding[];
|
|
11
|
+
readonly overallStatus: DiagnosticOverallStatus;
|
|
12
|
+
|
|
13
|
+
private constructor(findings: readonly DiagnosticFinding[]) {
|
|
14
|
+
const checkIds = new Set<string>();
|
|
15
|
+
for (const finding of findings) {
|
|
16
|
+
if (checkIds.has(finding.checkId)) {
|
|
17
|
+
throw new Error(`DiagnosticReport contains duplicate checkId: ${finding.checkId}`);
|
|
18
|
+
}
|
|
19
|
+
checkIds.add(finding.checkId);
|
|
20
|
+
}
|
|
21
|
+
this.findings = Object.freeze([...findings]);
|
|
22
|
+
this.overallStatus = this.deriveStatus();
|
|
23
|
+
Object.freeze(this);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static create(findings: readonly DiagnosticFinding[]): DiagnosticReport {
|
|
27
|
+
return new DiagnosticReport(findings);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
hasRedFlag(): boolean {
|
|
31
|
+
return this.findings.some((finding) => finding.severity === "red");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
hasWarning(): boolean {
|
|
35
|
+
return this.findings.some((finding) => finding.severity === "warn" || finding.repairMode !== "mechanical");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toJSON() {
|
|
39
|
+
return {
|
|
40
|
+
overallStatus: this.overallStatus,
|
|
41
|
+
findings: this.findings.map((finding) => finding.toJSON()),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private deriveStatus(): DiagnosticOverallStatus {
|
|
46
|
+
if (this.findings.some((finding) => finding.severity === "red")) return "red";
|
|
47
|
+
if (this.findings.some((finding) => finding.severity === "warn" || finding.repairMode !== "mechanical")) {
|
|
48
|
+
return "warn";
|
|
49
|
+
}
|
|
50
|
+
return "green";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
export class Hash {
|
|
6
|
+
private static readonly PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
7
|
+
|
|
8
|
+
readonly value: string;
|
|
9
|
+
readonly algorithm = "sha256";
|
|
10
|
+
|
|
11
|
+
private constructor(value: string) {
|
|
12
|
+
this.value = value;
|
|
13
|
+
Object.freeze(this);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static from(value: string): Hash {
|
|
17
|
+
if (!Hash.PATTERN.test(value)) {
|
|
18
|
+
throw new Error("Hash must match sha256:<64 lowercase hex chars>");
|
|
19
|
+
}
|
|
20
|
+
return new Hash(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
equals(other: Hash): boolean {
|
|
24
|
+
return this.value === other.value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
toString(): string {
|
|
28
|
+
return this.value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
export interface ManagedBlockInput {
|
|
6
|
+
readonly start: string;
|
|
7
|
+
readonly end: string;
|
|
8
|
+
readonly content: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ManagedBlock {
|
|
12
|
+
readonly start: string;
|
|
13
|
+
readonly end: string;
|
|
14
|
+
readonly content: string;
|
|
15
|
+
|
|
16
|
+
private constructor(input: ManagedBlockInput) {
|
|
17
|
+
if (input.start.trim().length === 0 || input.end.trim().length === 0) {
|
|
18
|
+
throw new Error("ManagedBlock start and end markers are required");
|
|
19
|
+
}
|
|
20
|
+
this.start = input.start;
|
|
21
|
+
this.end = input.end;
|
|
22
|
+
this.content = input.content;
|
|
23
|
+
Object.freeze(this);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static create(input: ManagedBlockInput): ManagedBlock {
|
|
27
|
+
return new ManagedBlock(input);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
toJSON(): ManagedBlockInput {
|
|
31
|
+
return {
|
|
32
|
+
start: this.start,
|
|
33
|
+
end: this.end,
|
|
34
|
+
content: this.content,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import type { FileInspectorPort } from "../../application/ports/file-inspector-port.js";
|
|
6
|
+
import type { CheckId } from "../check-id.js";
|
|
7
|
+
import type { DiagnosticFinding } from "../diagnostic-finding.js";
|
|
8
|
+
|
|
9
|
+
export interface HeuristicCheck {
|
|
10
|
+
readonly checkId: CheckId;
|
|
11
|
+
run(projectRoot: string, inspector: FileInspectorPort): Promise<DiagnosticFinding | null>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
export interface MergeResult<T> {
|
|
6
|
+
readonly merged: T;
|
|
7
|
+
readonly changed: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MergeStrategy<T> {
|
|
11
|
+
readonly fileType: "json" | "shell" | "yaml-add" | "package-json";
|
|
12
|
+
merge(existing: T | null, incoming: T): MergeResult<T>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import type { DeploymentManifest } from "../deployment-manifest.js";
|
|
6
|
+
|
|
7
|
+
export interface ReconcilePlan {
|
|
8
|
+
readonly operations: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ReconcileStrategy {
|
|
12
|
+
readonly fromVersion: string;
|
|
13
|
+
readonly toVersion: string;
|
|
14
|
+
plan(currentManifest: DeploymentManifest): ReconcilePlan;
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import type { ManagedBlock } from "../managed-block.js";
|
|
6
|
+
|
|
7
|
+
export interface UninstallReverseStrategy {
|
|
8
|
+
readonly fileType: "json" | "shell" | "yaml-add" | "package-json";
|
|
9
|
+
reverse(currentContent: string, block: ManagedBlock): string;
|
|
10
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
export const REPAIR_MODES = ["mechanical", "ai-assisted", "manual"] as const;
|
|
6
|
+
|
|
7
|
+
export type RepairMode = (typeof REPAIR_MODES)[number];
|
|
8
|
+
|
|
9
|
+
export function isRepairMode(value: string): value is RepairMode {
|
|
10
|
+
return (REPAIR_MODES as readonly string[]).includes(value);
|
|
11
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import type { CheckId } from "./check-id.js";
|
|
6
|
+
import { SuggestedSkill } from "./suggested-skill.js";
|
|
7
|
+
|
|
8
|
+
const CONFIG_DOCTOR = SuggestedSkill.create({
|
|
9
|
+
skillName: "phasegate-config-doctor",
|
|
10
|
+
rationale: "既存設定にユーザーのカスタマイズがある場合、merge 位置と保持方針の判断が必要です。",
|
|
11
|
+
invokeCommand: "invoke /phasegate-config-doctor",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const TOOLKIT_GUIDE = SuggestedSkill.create({
|
|
15
|
+
skillName: "phasegate-toolkit-guide",
|
|
16
|
+
rationale: "既存 CI workflow との意味的な競合は人間の判断が必要です。",
|
|
17
|
+
invokeCommand: "invoke /phasegate-toolkit-guide",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export class RepairTable {
|
|
21
|
+
private readonly table: ReadonlyMap<CheckId, SuggestedSkill | null>;
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
this.table = new Map<CheckId, SuggestedSkill | null>([
|
|
25
|
+
["claude-hook-missing", CONFIG_DOCTOR],
|
|
26
|
+
["codex-hook-missing", CONFIG_DOCTOR],
|
|
27
|
+
["husky-pre-commit-missing", CONFIG_DOCTOR],
|
|
28
|
+
["husky-commit-msg-missing", CONFIG_DOCTOR],
|
|
29
|
+
["husky-pre-push-missing", null],
|
|
30
|
+
["ci-workflow-missing", TOOLKIT_GUIDE],
|
|
31
|
+
["package-json-devdep-missing", null],
|
|
32
|
+
["claude-skills-symlink", null],
|
|
33
|
+
["codex-skills-symlink", null],
|
|
34
|
+
]);
|
|
35
|
+
Object.freeze(this);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
lookup(checkId: CheckId): SuggestedSkill | null {
|
|
39
|
+
return this.table.get(checkId) ?? null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer domain
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
export interface SuggestedSkillInput {
|
|
6
|
+
readonly skillName: string;
|
|
7
|
+
readonly rationale: string;
|
|
8
|
+
readonly invokeCommand: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SuggestedSkill {
|
|
12
|
+
readonly skillName: string;
|
|
13
|
+
readonly rationale: string;
|
|
14
|
+
readonly invokeCommand: string;
|
|
15
|
+
|
|
16
|
+
private constructor(input: SuggestedSkillInput) {
|
|
17
|
+
if (input.skillName.trim().length === 0 || input.invokeCommand.trim().length === 0) {
|
|
18
|
+
throw new Error("SuggestedSkill skillName and invokeCommand are required");
|
|
19
|
+
}
|
|
20
|
+
this.skillName = input.skillName;
|
|
21
|
+
this.rationale = input.rationale;
|
|
22
|
+
this.invokeCommand = input.invokeCommand;
|
|
23
|
+
Object.freeze(this);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static create(input: SuggestedSkillInput): SuggestedSkill {
|
|
27
|
+
return new SuggestedSkill(input);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
toJSON(): SuggestedSkillInput {
|
|
31
|
+
return {
|
|
32
|
+
skillName: this.skillName,
|
|
33
|
+
rationale: this.rationale,
|
|
34
|
+
invokeCommand: this.invokeCommand,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer infrastructure
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, rename, access } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { DeploymentManifest, type DeploymentManifestJson } from "../../domain/deployment-manifest.js";
|
|
9
|
+
import type { ManifestRepositoryPort } from "../../application/ports/manifest-repository-port.js";
|
|
10
|
+
|
|
11
|
+
export class ManifestParseError extends Error {
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "ManifestParseError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class FileSystemManifestRepositoryAdapter implements ManifestRepositoryPort {
|
|
19
|
+
async load(projectRoot: string): Promise<DeploymentManifest | null> {
|
|
20
|
+
const manifestPath = this.manifestPath(projectRoot);
|
|
21
|
+
let raw: string;
|
|
22
|
+
try {
|
|
23
|
+
raw = await readFile(manifestPath, "utf8");
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return DeploymentManifest.fromJSON(JSON.parse(raw) as DeploymentManifestJson);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
throw new ManifestParseError(`Failed to parse .phasegate/manifest.json: ${message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async save(projectRoot: string, manifest: DeploymentManifest): Promise<void> {
|
|
36
|
+
const phasegateDir = join(projectRoot, ".phasegate");
|
|
37
|
+
await mkdir(phasegateDir, { recursive: true });
|
|
38
|
+
const tmpPath = join(phasegateDir, `${randomUUID()}.tmp`);
|
|
39
|
+
const manifestPath = this.manifestPath(projectRoot);
|
|
40
|
+
const content = `${JSON.stringify(manifest.toJSON(), null, 2)}\n`;
|
|
41
|
+
const { writeFile } = await import("node:fs/promises");
|
|
42
|
+
await writeFile(tmpPath, content, "utf8");
|
|
43
|
+
await rename(tmpPath, manifestPath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async exists(projectRoot: string): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
await access(this.manifestPath(projectRoot));
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async archive(_projectRoot: string): Promise<void> {
|
|
56
|
+
throw new Error("Not yet implemented: manifest archive is owned by WI-147");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private manifestPath(projectRoot: string): string {
|
|
60
|
+
return join(projectRoot, ".phasegate", "manifest.json");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer infrastructure
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { Hash } from "../../domain/hash.js";
|
|
7
|
+
import type { HashCalculatorPort } from "../../application/ports/hash-calculator-port.js";
|
|
8
|
+
|
|
9
|
+
export class NodeCryptoHashAdapter implements HashCalculatorPort {
|
|
10
|
+
compute(content: string | Buffer): Hash {
|
|
11
|
+
return Hash.from(`sha256:${createHash("sha256").update(content).digest("hex")}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/scripts/harness/installation/infrastructure/adapters/node-fs-file-inspector-adapter.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @unit installation
|
|
2
|
+
// @layer infrastructure
|
|
3
|
+
// @work-item-id WI-145
|
|
4
|
+
|
|
5
|
+
import { lstat, readFile, readlink, readdir } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { FileInspectorPort } from "../../application/ports/file-inspector-port.js";
|
|
8
|
+
|
|
9
|
+
export class NodeFsFileInspectorAdapter implements FileInspectorPort {
|
|
10
|
+
async exists(absolutePath: string): Promise<boolean> {
|
|
11
|
+
try {
|
|
12
|
+
await lstat(absolutePath);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async readText(absolutePath: string): Promise<string | null> {
|
|
20
|
+
try {
|
|
21
|
+
return await readFile(absolutePath, "utf8");
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async readJson<T = unknown>(absolutePath: string): Promise<T | null> {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(absolutePath, "utf8")) as T;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async readSymlink(absolutePath: string): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
const stats = await lstat(absolutePath);
|
|
38
|
+
if (!stats.isSymbolicLink()) return null;
|
|
39
|
+
return await readlink(absolutePath);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async listFiles(absolutePath: string): Promise<string[]> {
|
|
46
|
+
try {
|
|
47
|
+
const entries = await readdir(absolutePath, { withFileTypes: true });
|
|
48
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => join(absolutePath, entry.name));
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|