oh-my-harness 0.14.0 โ 0.16.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/README.md +55 -0
- package/dist/cli/commands/diff.d.ts +17 -0
- package/dist/cli/commands/diff.js +88 -0
- package/dist/cli/commands/doctor.d.ts +8 -0
- package/dist/cli/commands/doctor.js +28 -2
- package/dist/cli/commands/sync.d.ts +7 -1
- package/dist/cli/commands/sync.js +46 -6
- package/dist/cli/commands/uninstall.d.ts +16 -0
- package/dist/cli/commands/uninstall.js +76 -0
- package/dist/cli/index.js +36 -3
- package/dist/core/drift.d.ts +36 -0
- package/dist/core/drift.js +69 -0
- package/dist/core/generator.d.ts +12 -0
- package/dist/core/generator.js +40 -5
- package/dist/core/harness-schema.d.ts +4 -4
- package/dist/core/managed-hooks.d.ts +6 -0
- package/dist/core/managed-hooks.js +74 -0
- package/dist/core/plan.d.ts +24 -0
- package/dist/core/plan.js +1 -0
- package/dist/core/uninstall.d.ts +41 -0
- package/dist/core/uninstall.js +291 -0
- package/dist/generators/codex-config.d.ts +9 -0
- package/dist/generators/codex-config.js +104 -6
- package/dist/generators/gitignore.d.ts +9 -0
- package/dist/generators/gitignore.js +14 -3
- package/dist/generators/hooks.d.ts +21 -0
- package/dist/generators/hooks.js +48 -24
- package/dist/generators/managed-md.d.ts +5 -0
- package/dist/generators/managed-md.js +9 -1
- package/dist/generators/pi-extension.d.ts +8 -0
- package/dist/generators/pi-extension.js +17 -8
- package/dist/generators/settings.d.ts +10 -0
- package/dist/generators/settings.js +71 -4
- package/dist/utils/version.d.ts +1 -0
- package/dist/utils/version.js +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,8 +60,30 @@ omh init "Android Kotlin app with Hilt, JUnit, Gradle"
|
|
|
60
60
|
omh catalog list
|
|
61
61
|
omh test # Dry-run verify your harness
|
|
62
62
|
omh stats # TUI analytics dashboard
|
|
63
|
+
omh diff # Preview what `omh sync` would change
|
|
64
|
+
omh sync --check # Fail (exit 1) if generated files are out of date โ CI gate
|
|
63
65
|
```
|
|
64
66
|
|
|
67
|
+
### ๐ Keeping generated files in sync
|
|
68
|
+
|
|
69
|
+
`harness.yaml` is the source of truth, so the committed `CLAUDE.md`, hooks, and
|
|
70
|
+
runtime configs can drift if someone edits `harness.yaml` without re-running
|
|
71
|
+
`omh sync`. Three commands keep them honest:
|
|
72
|
+
|
|
73
|
+
| Command | Use |
|
|
74
|
+
|---------|-----|
|
|
75
|
+
| `omh sync --check` | CI gate โ exits non-zero (and lists the files) when generated output is stale, writes nothing |
|
|
76
|
+
| `omh diff` | Human preview of exactly what `omh sync` would change |
|
|
77
|
+
| `omh doctor --strict` | Health check that also fails on drift (plain `omh doctor` warns) |
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
# .github/workflows/ci.yml
|
|
81
|
+
- run: npx oh-my-harness sync --check # fails the build if the harness is out of date
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The hook manifest records the oh-my-harness version that generated it, so an
|
|
85
|
+
upgrade that changes output is surfaced as a "re-run `omh sync`" hint.
|
|
86
|
+
|
|
65
87
|
### ๐ What Gets Generated
|
|
66
88
|
|
|
67
89
|
```text
|
|
@@ -251,6 +273,9 @@ omh hook remove auto-pr # Remove a hook
|
|
|
251
273
|
|
|
252
274
|
# ๐ Sync & manage
|
|
253
275
|
omh sync # Regenerate all files from harness.yaml
|
|
276
|
+
omh uninstall --dry-run # Preview generated-file cleanup
|
|
277
|
+
omh uninstall -y # Remove generated files, keep user content
|
|
278
|
+
omh uninstall -y --purge # Also remove harness.yaml
|
|
254
279
|
|
|
255
280
|
# ๐ฉบ Verify & monitor
|
|
256
281
|
omh doctor # Health check
|
|
@@ -258,6 +283,35 @@ omh test # Dry-run verify all hooks
|
|
|
258
283
|
omh stats # TUI analytics dashboard
|
|
259
284
|
```
|
|
260
285
|
|
|
286
|
+
### ๐งน `omh uninstall` โ Safe generated-file cleanup
|
|
287
|
+
|
|
288
|
+
`omh uninstall` removes oh-my-harness generated artifacts while preserving user
|
|
289
|
+
content in merged files.
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
omh uninstall --dry-run
|
|
293
|
+
omh uninstall -y
|
|
294
|
+
omh uninstall -y --purge
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Safety behavior:
|
|
298
|
+
|
|
299
|
+
- Prints the same uninstall plan for dry-run and real execution.
|
|
300
|
+
- Recommends backing up before execution; use `--skip-backup-warning` only for
|
|
301
|
+
automation that already handles backups.
|
|
302
|
+
- Keeps `harness.yaml` by default; `--purge` removes it.
|
|
303
|
+
- Preserves user content in `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`,
|
|
304
|
+
`.codex/hooks.json`, `.codex/config.toml`, and user Pi extensions.
|
|
305
|
+
- Removes only OMH-owned hook commands that point at this project's
|
|
306
|
+
`.omh/hooks` directory.
|
|
307
|
+
- Warns when `.codex/config.toml` feature flags (`hooks`/`goals`) are removed,
|
|
308
|
+
because manually-owned feature settings cannot be distinguished from OMH
|
|
309
|
+
generated settings.
|
|
310
|
+
- Warns that `.codex/config.toml` comments may be lost when TOML is rewritten.
|
|
311
|
+
- Uses backups for modified files and restores them on stop-on-error failures;
|
|
312
|
+
`--continue-on-error` records failures and keeps applying independent
|
|
313
|
+
operations.
|
|
314
|
+
|
|
261
315
|
### ๐ฉบ `omh doctor`
|
|
262
316
|
|
|
263
317
|
```text
|
|
@@ -418,6 +472,7 @@ oh-my-harness/
|
|
|
418
472
|
- [x] Unified `.omh/` layout โ single source of truth for hooks & state across runtimes
|
|
419
473
|
- [x] Pi ([pi.dev](https://pi.dev)) emitter โ bridge extension (`.pi/extensions/omh-harness.ts`) reusing the same `.omh/hooks/*.sh`
|
|
420
474
|
- [x] `ask` mode โ request approval before executing risky tools (Claude native prompt / Pi `ctx.ui.select`; Codex falls back to block)
|
|
475
|
+
- [x] `omh uninstall` โ remove generated artifacts while preserving user content
|
|
421
476
|
- [ ] Community harness.yaml registry โ share and reuse configs
|
|
422
477
|
- [ ] `omh modify "change X"` โ NL config editing
|
|
423
478
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface DiffOptions {
|
|
2
|
+
projectDir?: string;
|
|
3
|
+
}
|
|
4
|
+
export interface DiffResult {
|
|
5
|
+
exitCode: number;
|
|
6
|
+
inSync?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Minimal LCS line diff โ enough to show what `omh sync` would change without
|
|
10
|
+
* pulling in a diff dependency. Returns unified-style lines (" ", "-", "+").
|
|
11
|
+
*/
|
|
12
|
+
export declare function unifiedDiff(oldStr: string, newStr: string): string[];
|
|
13
|
+
/**
|
|
14
|
+
* `omh diff`: human-readable preview of what `omh sync` would change, without
|
|
15
|
+
* writing anything.
|
|
16
|
+
*/
|
|
17
|
+
export declare function diffCommand(options?: DiffOptions): Promise<DiffResult>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal LCS line diff โ enough to show what `omh sync` would change without
|
|
5
|
+
* pulling in a diff dependency. Returns unified-style lines (" ", "-", "+").
|
|
6
|
+
*/
|
|
7
|
+
export function unifiedDiff(oldStr, newStr) {
|
|
8
|
+
const a = oldStr.split("\n");
|
|
9
|
+
const b = newStr.split("\n");
|
|
10
|
+
const n = a.length;
|
|
11
|
+
const m = b.length;
|
|
12
|
+
// LCS length table.
|
|
13
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
14
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
15
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
16
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const out = [];
|
|
20
|
+
let i = 0;
|
|
21
|
+
let j = 0;
|
|
22
|
+
while (i < n && j < m) {
|
|
23
|
+
if (a[i] === b[j]) {
|
|
24
|
+
out.push(` ${a[i]}`);
|
|
25
|
+
i++;
|
|
26
|
+
j++;
|
|
27
|
+
}
|
|
28
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
29
|
+
out.push(`- ${a[i]}`);
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
out.push(`+ ${b[j]}`);
|
|
34
|
+
j++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
while (i < n)
|
|
38
|
+
out.push(`- ${a[i++]}`);
|
|
39
|
+
while (j < m)
|
|
40
|
+
out.push(`+ ${b[j++]}`);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function printChange(change) {
|
|
44
|
+
const header = change.kind === "create" ? `+++ ${change.path} (new)` : `~~~ ${change.path}`;
|
|
45
|
+
console.log(chalk.bold(header));
|
|
46
|
+
const lines = unifiedDiff(change.current ?? "", change.planned);
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (line.startsWith("+"))
|
|
49
|
+
console.log(chalk.green(line));
|
|
50
|
+
else if (line.startsWith("-"))
|
|
51
|
+
console.log(chalk.red(line));
|
|
52
|
+
else
|
|
53
|
+
console.log(chalk.dim(line));
|
|
54
|
+
}
|
|
55
|
+
console.log("");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `omh diff`: human-readable preview of what `omh sync` would change, without
|
|
59
|
+
* writing anything.
|
|
60
|
+
*/
|
|
61
|
+
export async function diffCommand(options = {}) {
|
|
62
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
63
|
+
let drift;
|
|
64
|
+
try {
|
|
65
|
+
drift = await computeDrift(projectDir);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof HarnessNotFoundError) {
|
|
69
|
+
console.error(chalk.red(err.message));
|
|
70
|
+
console.error("Run `oh-my-harness init` to create one.");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.error(chalk.red(`omh diff failed: ${err.message}`));
|
|
74
|
+
}
|
|
75
|
+
return { exitCode: 1 };
|
|
76
|
+
}
|
|
77
|
+
if (drift.inSync) {
|
|
78
|
+
console.log(chalk.green("oh-my-harness: no changes โ generated files are up to date"));
|
|
79
|
+
return { exitCode: 0, inSync: true };
|
|
80
|
+
}
|
|
81
|
+
for (const change of drift.changed) {
|
|
82
|
+
printChange(change);
|
|
83
|
+
}
|
|
84
|
+
for (const stale of drift.wouldDelete) {
|
|
85
|
+
console.log(chalk.red(`--- ${stale} (would be removed)`));
|
|
86
|
+
}
|
|
87
|
+
return { exitCode: 0, inSync: false };
|
|
88
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface DoctorOptions {
|
|
2
2
|
projectDir?: string;
|
|
3
|
+
/** When true, treat drift (out-of-sync generated files) as a health failure. */
|
|
4
|
+
strict?: boolean;
|
|
3
5
|
}
|
|
4
6
|
export interface DoctorResult {
|
|
5
7
|
healthy: boolean;
|
|
@@ -13,6 +15,12 @@ export interface DoctorResult {
|
|
|
13
15
|
piConfig: boolean;
|
|
14
16
|
hooksExecutable: boolean;
|
|
15
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Whether generated files are up to date with harness.yaml. undefined when
|
|
20
|
+
* there is no harness.yaml (drift not evaluated). Drift is a non-fatal
|
|
21
|
+
* warning unless `strict` is set.
|
|
22
|
+
*/
|
|
23
|
+
inSync?: boolean;
|
|
16
24
|
messages: string[];
|
|
17
25
|
}
|
|
18
26
|
export declare function doctorCommand(options?: DoctorOptions): Promise<DoctorResult>;
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse } from "smol-toml";
|
|
4
4
|
import { OMH_HOOKS_DIR } from "../../utils/paths.js";
|
|
5
|
+
import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
|
|
5
6
|
export async function doctorCommand(options = {}) {
|
|
6
7
|
const projectDir = options.projectDir ?? process.cwd();
|
|
7
8
|
const messages = [];
|
|
@@ -132,10 +133,35 @@ export async function doctorCommand(options = {}) {
|
|
|
132
133
|
// No hooks dir โ acceptable if no hooks defined
|
|
133
134
|
checks.hooksExecutable = true;
|
|
134
135
|
}
|
|
135
|
-
|
|
136
|
+
// 8. Drift: are generated files up to date with harness.yaml? Only evaluated
|
|
137
|
+
// when a harness.yaml is present. Non-fatal by default (a warning), fatal
|
|
138
|
+
// under --strict so CI can gate on it.
|
|
139
|
+
let inSync;
|
|
140
|
+
try {
|
|
141
|
+
const drift = await computeDrift(projectDir);
|
|
142
|
+
inSync = drift.inSync;
|
|
143
|
+
if (!drift.inSync) {
|
|
144
|
+
messages.push(`${options.strict ? "FAIL" : "WARN"}: generated files are out of sync with harness.yaml โ run \`omh sync\`.`);
|
|
145
|
+
}
|
|
146
|
+
else if (drift.versionDrift) {
|
|
147
|
+
messages.push(`WARN: harness generated by oh-my-harness ${drift.versionDrift.manifestVersion}, ` +
|
|
148
|
+
`now running ${drift.versionDrift.currentVersion} โ run \`omh sync\` to refresh.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (!(err instanceof HarnessNotFoundError)) {
|
|
153
|
+
messages.push(`WARN: could not evaluate sync status: ${err.message}`);
|
|
154
|
+
}
|
|
155
|
+
// No harness.yaml โ leave inSync undefined (drift not applicable).
|
|
156
|
+
}
|
|
157
|
+
const checksHealthy = Object.values(checks).every(Boolean);
|
|
158
|
+
const healthy = checksHealthy && (!options.strict || inSync !== false);
|
|
136
159
|
const exitCode = healthy ? 0 : 1;
|
|
137
160
|
if (healthy) {
|
|
138
161
|
console.log("oh-my-harness: all checks passed");
|
|
162
|
+
for (const msg of messages) {
|
|
163
|
+
console.log(` ${msg}`);
|
|
164
|
+
}
|
|
139
165
|
}
|
|
140
166
|
else {
|
|
141
167
|
console.log("oh-my-harness: some checks failed:");
|
|
@@ -143,5 +169,5 @@ export async function doctorCommand(options = {}) {
|
|
|
143
169
|
console.log(` ${msg}`);
|
|
144
170
|
}
|
|
145
171
|
}
|
|
146
|
-
return { healthy, exitCode, checks, messages };
|
|
172
|
+
return { healthy, exitCode, checks, inSync, messages };
|
|
147
173
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export interface SyncOptions {
|
|
2
2
|
projectDir?: string;
|
|
3
|
+
/** When true, report drift without writing and exit non-zero if out of date. */
|
|
4
|
+
check?: boolean;
|
|
3
5
|
}
|
|
4
|
-
export
|
|
6
|
+
export interface SyncResult {
|
|
7
|
+
exitCode: number;
|
|
8
|
+
inSync?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function syncCommand(options?: SyncOptions): Promise<SyncResult>;
|
|
@@ -5,10 +5,14 @@ import chalk from "chalk";
|
|
|
5
5
|
import { HarnessConfigSchema } from "../../core/harness-schema.js";
|
|
6
6
|
import { harnessToMergedConfigV2 } from "../../core/harness-converter-v2.js";
|
|
7
7
|
import { generate } from "../../core/generator.js";
|
|
8
|
+
import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
|
|
8
9
|
import { writeHarnessState } from "./init.js";
|
|
9
10
|
export async function syncCommand(options = {}) {
|
|
10
11
|
const projectDir = options.projectDir ?? process.cwd();
|
|
11
12
|
const harnessYamlPath = path.join(projectDir, "harness.yaml");
|
|
13
|
+
if (options.check) {
|
|
14
|
+
return runCheck(projectDir);
|
|
15
|
+
}
|
|
12
16
|
let raw;
|
|
13
17
|
try {
|
|
14
18
|
raw = await fs.readFile(harnessYamlPath, "utf-8");
|
|
@@ -22,8 +26,7 @@ export async function syncCommand(options = {}) {
|
|
|
22
26
|
else {
|
|
23
27
|
console.error(chalk.red(`Failed to read harness.yaml: ${error.message}`));
|
|
24
28
|
}
|
|
25
|
-
|
|
26
|
-
return;
|
|
29
|
+
return { exitCode: 1 };
|
|
27
30
|
}
|
|
28
31
|
let parsed;
|
|
29
32
|
try {
|
|
@@ -32,8 +35,7 @@ export async function syncCommand(options = {}) {
|
|
|
32
35
|
catch (err) {
|
|
33
36
|
const error = err;
|
|
34
37
|
console.error(chalk.red(`Failed to parse harness.yaml: ${error.message}`));
|
|
35
|
-
|
|
36
|
-
return;
|
|
38
|
+
return { exitCode: 1 };
|
|
37
39
|
}
|
|
38
40
|
const result = HarnessConfigSchema.safeParse(parsed);
|
|
39
41
|
if (!result.success) {
|
|
@@ -41,8 +43,7 @@ export async function syncCommand(options = {}) {
|
|
|
41
43
|
for (const issue of result.error.issues) {
|
|
42
44
|
console.error(` ${issue.path.join(".")}: ${issue.message}`);
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
return;
|
|
46
|
+
return { exitCode: 1 };
|
|
46
47
|
}
|
|
47
48
|
const harness = result.data;
|
|
48
49
|
const mergedV2 = await harnessToMergedConfigV2(harness, undefined, projectDir);
|
|
@@ -58,4 +59,43 @@ export async function syncCommand(options = {}) {
|
|
|
58
59
|
for (const f of genResult.files) {
|
|
59
60
|
console.log(` ${f}`);
|
|
60
61
|
}
|
|
62
|
+
return { exitCode: 0 };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `omh sync --check`: report whether generated files are up to date with
|
|
66
|
+
* harness.yaml without writing anything. exitCode 0 = in sync, 1 = drift (or a
|
|
67
|
+
* harness.yaml problem). CI-friendly, like `prettier --check`.
|
|
68
|
+
*/
|
|
69
|
+
async function runCheck(projectDir) {
|
|
70
|
+
let drift;
|
|
71
|
+
try {
|
|
72
|
+
drift = await computeDrift(projectDir);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof HarnessNotFoundError) {
|
|
76
|
+
console.error(chalk.red(err.message));
|
|
77
|
+
console.error("Run `oh-my-harness init` to create one.");
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
console.error(chalk.red(`sync --check failed: ${err.message}`));
|
|
81
|
+
}
|
|
82
|
+
return { exitCode: 1 };
|
|
83
|
+
}
|
|
84
|
+
if (drift.inSync) {
|
|
85
|
+
console.log(chalk.green("oh-my-harness: up to date"));
|
|
86
|
+
if (drift.versionDrift) {
|
|
87
|
+
console.log(chalk.yellow(` โ generated by oh-my-harness ${drift.versionDrift.manifestVersion}, ` +
|
|
88
|
+
`now running ${drift.versionDrift.currentVersion} โ re-run \`omh sync\` to refresh.`));
|
|
89
|
+
}
|
|
90
|
+
return { exitCode: 0, inSync: true };
|
|
91
|
+
}
|
|
92
|
+
console.error(chalk.red("oh-my-harness: out of sync โ run `omh sync`"));
|
|
93
|
+
for (const change of drift.changed) {
|
|
94
|
+
const label = change.kind === "create" ? "would create" : "would update";
|
|
95
|
+
console.error(` ${label}: ${change.path}`);
|
|
96
|
+
}
|
|
97
|
+
for (const stale of drift.wouldDelete) {
|
|
98
|
+
console.error(` would delete: ${stale}`);
|
|
99
|
+
}
|
|
100
|
+
return { exitCode: 1, inSync: false };
|
|
61
101
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type UninstallPlan, type UninstallResult } from "../../core/uninstall.js";
|
|
2
|
+
export interface UninstallOptions {
|
|
3
|
+
projectDir?: string;
|
|
4
|
+
dryRun?: boolean;
|
|
5
|
+
yes?: boolean;
|
|
6
|
+
purge?: boolean;
|
|
7
|
+
skipBackupWarning?: boolean;
|
|
8
|
+
continueOnError?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface UninstallCommandResult {
|
|
11
|
+
exitCode: number;
|
|
12
|
+
plan: UninstallPlan;
|
|
13
|
+
result?: UninstallResult;
|
|
14
|
+
}
|
|
15
|
+
export declare function renderUninstallPlan(plan: UninstallPlan, options?: UninstallOptions): string;
|
|
16
|
+
export declare function uninstallCommand(options?: UninstallOptions): Promise<UninstallCommandResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { applyUninstallPlan, computeUninstall, } from "../../core/uninstall.js";
|
|
4
|
+
function visibleWarnings(plan, options) {
|
|
5
|
+
return plan.destructiveWarnings.filter((warning) => !(options.skipBackupWarning && warning.includes("๋ฐฑ์
ํ ์คํ ๊ถ์ฅ")));
|
|
6
|
+
}
|
|
7
|
+
export function renderUninstallPlan(plan, options = {}) {
|
|
8
|
+
const warnings = visibleWarnings(plan, options);
|
|
9
|
+
const lines = [
|
|
10
|
+
"oh-my-harness uninstall plan",
|
|
11
|
+
`- delete: ${plan.delete.length} files/directories`,
|
|
12
|
+
`- modify: ${plan.modify.length} files`,
|
|
13
|
+
`- keep: ${plan.keptHarnessYaml ? "harness.yaml" : "none"}`,
|
|
14
|
+
`- warnings: ${warnings.length}`,
|
|
15
|
+
];
|
|
16
|
+
if (warnings.length > 0) {
|
|
17
|
+
lines.push("", "Safety:");
|
|
18
|
+
for (const warning of warnings)
|
|
19
|
+
lines.push(`- ${warning}`);
|
|
20
|
+
}
|
|
21
|
+
if (plan.modify.length > 0) {
|
|
22
|
+
lines.push("", "modify:");
|
|
23
|
+
for (const item of plan.modify)
|
|
24
|
+
lines.push(` ${item.path}`);
|
|
25
|
+
}
|
|
26
|
+
if (plan.delete.length > 0) {
|
|
27
|
+
lines.push("", "delete:");
|
|
28
|
+
for (const target of plan.delete)
|
|
29
|
+
lines.push(` ${target}`);
|
|
30
|
+
}
|
|
31
|
+
if (plan.removeDirs.length > 0) {
|
|
32
|
+
lines.push("", "remove empty dirs:");
|
|
33
|
+
for (const dir of plan.removeDirs)
|
|
34
|
+
lines.push(` ${dir}`);
|
|
35
|
+
}
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
function renderResult(result) {
|
|
39
|
+
const lines = [
|
|
40
|
+
"oh-my-harness uninstall result",
|
|
41
|
+
`modified: ${result.modified.length}`,
|
|
42
|
+
`deleted: ${result.deleted.length}`,
|
|
43
|
+
`removedDirs: ${result.removedDirs.length}`,
|
|
44
|
+
`restored: ${result.restored.length}`,
|
|
45
|
+
`failed: ${result.failed.length}`,
|
|
46
|
+
];
|
|
47
|
+
for (const failure of result.failed) {
|
|
48
|
+
lines.push(` ${failure.op}: ${failure.path} โ ${failure.message}`);
|
|
49
|
+
}
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
async function confirmUninstall() {
|
|
53
|
+
const rl = createInterface({ input, output });
|
|
54
|
+
try {
|
|
55
|
+
const answer = await rl.question("Proceed with uninstall? Type 'yes' to continue: ");
|
|
56
|
+
return answer.trim().toLowerCase() === "yes";
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
rl.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function uninstallCommand(options = {}) {
|
|
63
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
64
|
+
const plan = await computeUninstall({ projectDir, purge: options.purge });
|
|
65
|
+
console.log(renderUninstallPlan(plan, options));
|
|
66
|
+
if (options.dryRun) {
|
|
67
|
+
return { exitCode: 0, plan };
|
|
68
|
+
}
|
|
69
|
+
if (!options.yes && !await confirmUninstall()) {
|
|
70
|
+
console.log("oh-my-harness: uninstall cancelled");
|
|
71
|
+
return { exitCode: 1, plan };
|
|
72
|
+
}
|
|
73
|
+
const result = await applyUninstallPlan(plan, { continueOnError: options.continueOnError });
|
|
74
|
+
console.log(renderResult(result));
|
|
75
|
+
return { exitCode: result.failed.length === 0 ? 0 : 1, plan, result };
|
|
76
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -31,9 +31,22 @@ export function createCli() {
|
|
|
31
31
|
program
|
|
32
32
|
.command("doctor")
|
|
33
33
|
.description("Validate harness configuration health")
|
|
34
|
-
.
|
|
34
|
+
.option("-d, --project-dir <dir>", "Project directory")
|
|
35
|
+
.option("--strict", "Treat drift (out-of-sync generated files) as a failure")
|
|
36
|
+
.action(async (options) => {
|
|
35
37
|
const { doctorCommand } = await import("./commands/doctor.js");
|
|
36
|
-
const result = await doctorCommand();
|
|
38
|
+
const result = await doctorCommand(options);
|
|
39
|
+
if (result.exitCode !== 0) {
|
|
40
|
+
process.exitCode = result.exitCode;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
program
|
|
44
|
+
.command("diff")
|
|
45
|
+
.description("Preview what `omh sync` would change, without writing")
|
|
46
|
+
.option("-d, --project-dir <dir>", "Project directory")
|
|
47
|
+
.action(async (options) => {
|
|
48
|
+
const { diffCommand } = await import("./commands/diff.js");
|
|
49
|
+
const result = await diffCommand(options);
|
|
37
50
|
if (result.exitCode !== 0) {
|
|
38
51
|
process.exitCode = result.exitCode;
|
|
39
52
|
}
|
|
@@ -50,9 +63,29 @@ export function createCli() {
|
|
|
50
63
|
.command("sync")
|
|
51
64
|
.description("Regenerate files from harness.yaml")
|
|
52
65
|
.option("-d, --project-dir <dir>", "Project directory")
|
|
66
|
+
.option("--check", "Report drift without writing; exit non-zero if out of date")
|
|
53
67
|
.action(async (options) => {
|
|
54
68
|
const { syncCommand } = await import("./commands/sync.js");
|
|
55
|
-
await syncCommand(options);
|
|
69
|
+
const result = await syncCommand(options);
|
|
70
|
+
if (result.exitCode !== 0) {
|
|
71
|
+
process.exitCode = result.exitCode;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
program
|
|
75
|
+
.command("uninstall")
|
|
76
|
+
.description("Safely remove oh-my-harness generated files while preserving user content")
|
|
77
|
+
.option("-d, --project-dir <dir>", "Project directory")
|
|
78
|
+
.option("--dry-run", "Print the uninstall plan without writing")
|
|
79
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
80
|
+
.option("--purge", "Also delete harness.yaml")
|
|
81
|
+
.option("--skip-backup-warning", "Suppress the backup recommendation")
|
|
82
|
+
.option("--continue-on-error", "Keep applying independent operations after failures")
|
|
83
|
+
.action(async (options) => {
|
|
84
|
+
const { uninstallCommand } = await import("./commands/uninstall.js");
|
|
85
|
+
const result = await uninstallCommand(options);
|
|
86
|
+
if (result.exitCode !== 0) {
|
|
87
|
+
process.exitCode = result.exitCode;
|
|
88
|
+
}
|
|
56
89
|
});
|
|
57
90
|
program
|
|
58
91
|
.command("stats")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface DriftChange {
|
|
2
|
+
path: string;
|
|
3
|
+
/** "create": file is missing on disk; "update": content differs. */
|
|
4
|
+
kind: "create" | "update";
|
|
5
|
+
/** Content sync would write. */
|
|
6
|
+
planned: string;
|
|
7
|
+
/** Current on-disk content, or null when the file is missing. */
|
|
8
|
+
current: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface DriftResult {
|
|
11
|
+
/** True when no files would change and nothing would be deleted. */
|
|
12
|
+
inSync: boolean;
|
|
13
|
+
changed: DriftChange[];
|
|
14
|
+
/** Absolute paths of stale files a sync would remove. */
|
|
15
|
+
wouldDelete: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Set when the manifest records a different oh-my-harness version than the
|
|
18
|
+
* one running now (a cheap "re-sync recommended after upgrade" signal). null
|
|
19
|
+
* when versions match or no manifest exists.
|
|
20
|
+
*/
|
|
21
|
+
versionDrift: {
|
|
22
|
+
manifestVersion: string | null;
|
|
23
|
+
currentVersion: string;
|
|
24
|
+
} | null;
|
|
25
|
+
}
|
|
26
|
+
/** Raised when computeDrift is asked to run on a project without a harness.yaml. */
|
|
27
|
+
export declare class HarnessNotFoundError extends Error {
|
|
28
|
+
readonly harnessPath: string;
|
|
29
|
+
constructor(harnessPath: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Compare what a sync would produce against the files currently on disk, without
|
|
33
|
+
* writing anything. Reads harness.yaml, builds the merged config, runs the
|
|
34
|
+
* generator in plan mode, and diffs each planned file against disk.
|
|
35
|
+
*/
|
|
36
|
+
export declare function computeDrift(projectDir: string): Promise<DriftResult>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { HarnessConfigSchema } from "./harness-schema.js";
|
|
5
|
+
import { harnessToMergedConfigV2 } from "./harness-converter-v2.js";
|
|
6
|
+
import { planGenerate } from "./generator.js";
|
|
7
|
+
import { OMH_MANIFEST } from "../utils/paths.js";
|
|
8
|
+
import { OMH_VERSION } from "../utils/version.js";
|
|
9
|
+
/** Raised when computeDrift is asked to run on a project without a harness.yaml. */
|
|
10
|
+
export class HarnessNotFoundError extends Error {
|
|
11
|
+
harnessPath;
|
|
12
|
+
constructor(harnessPath) {
|
|
13
|
+
super(`harness.yaml not found at ${harnessPath}`);
|
|
14
|
+
this.harnessPath = harnessPath;
|
|
15
|
+
this.name = "HarnessNotFoundError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function readFileOrNull(path) {
|
|
19
|
+
try {
|
|
20
|
+
return await readFile(path, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err.code === "ENOENT")
|
|
24
|
+
return null;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function readManifestVersion(projectDir) {
|
|
29
|
+
const raw = await readFileOrNull(join(projectDir, OMH_MANIFEST));
|
|
30
|
+
if (raw === null)
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const manifest = JSON.parse(raw);
|
|
34
|
+
return typeof manifest.omhVersion === "string" ? manifest.omhVersion : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compare what a sync would produce against the files currently on disk, without
|
|
42
|
+
* writing anything. Reads harness.yaml, builds the merged config, runs the
|
|
43
|
+
* generator in plan mode, and diffs each planned file against disk.
|
|
44
|
+
*/
|
|
45
|
+
export async function computeDrift(projectDir) {
|
|
46
|
+
const harnessPath = join(projectDir, "harness.yaml");
|
|
47
|
+
const raw = await readFileOrNull(harnessPath);
|
|
48
|
+
if (raw === null)
|
|
49
|
+
throw new HarnessNotFoundError(harnessPath);
|
|
50
|
+
const parsed = HarnessConfigSchema.parse(yaml.load(raw));
|
|
51
|
+
const config = await harnessToMergedConfigV2(parsed, undefined, projectDir);
|
|
52
|
+
const plan = await planGenerate({ projectDir, config });
|
|
53
|
+
const changed = [];
|
|
54
|
+
for (const file of plan.files) {
|
|
55
|
+
const current = await readFileOrNull(file.path);
|
|
56
|
+
if (current === null) {
|
|
57
|
+
changed.push({ path: file.path, kind: "create", planned: file.content, current: null });
|
|
58
|
+
}
|
|
59
|
+
else if (current !== file.content) {
|
|
60
|
+
changed.push({ path: file.path, kind: "update", planned: file.content, current });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const manifestVersion = await readManifestVersion(projectDir);
|
|
64
|
+
const versionDrift = manifestVersion !== null && manifestVersion !== OMH_VERSION
|
|
65
|
+
? { manifestVersion, currentVersion: OMH_VERSION }
|
|
66
|
+
: null;
|
|
67
|
+
const inSync = changed.length === 0 && plan.wouldDelete.length === 0;
|
|
68
|
+
return { inSync, changed, wouldDelete: plan.wouldDelete, versionDrift };
|
|
69
|
+
}
|
package/dist/core/generator.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MergedConfig } from "./merged-config.js";
|
|
2
|
+
import type { GenerationPlan } from "./plan.js";
|
|
2
3
|
export interface GenerateOptions {
|
|
3
4
|
projectDir: string;
|
|
4
5
|
config: MergedConfig;
|
|
@@ -7,3 +8,14 @@ export interface GenerateResult {
|
|
|
7
8
|
files: string[];
|
|
8
9
|
}
|
|
9
10
|
export declare function generate(options: GenerateOptions): Promise<GenerateResult>;
|
|
11
|
+
/**
|
|
12
|
+
* Compute every file `generate()` would write โ and the stale files it would
|
|
13
|
+
* remove โ WITHOUT touching disk. Backs `omh sync --check`, `omh diff`, and the
|
|
14
|
+
* doctor drift warning. Uses the same compute functions as the write path, so
|
|
15
|
+
* the plan can never disagree with what a real sync produces.
|
|
16
|
+
*
|
|
17
|
+
* The bookkeeping files that embed timestamps (.omh/manifest.json and
|
|
18
|
+
* .claude/oh-my-harness.json) are intentionally excluded โ they change every
|
|
19
|
+
* run and are not part of the reproducible harness output.
|
|
20
|
+
*/
|
|
21
|
+
export declare function planGenerate(options: GenerateOptions): Promise<GenerationPlan>;
|