canary-test-cli 6.8.0 → 7.0.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/dist/engine/analysis/cli.js +155 -45
- package/dist/engine/analysis/engine.js +9 -9
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/cli-commands.js +2 -2
- package/dist/engine/core/migrator.js +142 -31
- package/dist/engine/core/static-linter.js +2 -2
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +1 -1
- package/dist/engine/guardian/agent-tier.js +3 -3
- package/dist/engine/guardian/coverage.js +15 -1397
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
- package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
- package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
- package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
- package/dist/engine/guardian/diff-coverage/paths.js +164 -0
- package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
- package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
- package/dist/engine/guardian/diff-coverage/types.js +115 -0
- package/dist/engine/guardian/pr-check.js +22 -7
- package/dist/engine-checks.d.ts +15 -0
- package/dist/engine-checks.js +92 -1
- package/dist/overlay-commands.d.ts +12 -1
- package/dist/overlay-commands.js +28 -2
- package/dist/router.js +17 -5
- package/dist/uninstall-render.d.ts +11 -0
- package/dist/uninstall-render.js +60 -0
- package/dist/uninstall-scan.d.ts +14 -0
- package/dist/uninstall-scan.js +273 -0
- package/dist/uninstall-types.d.ts +46 -0
- package/dist/uninstall-types.js +91 -0
- package/dist/uninstall.d.ts +13 -0
- package/dist/uninstall.js +174 -0
- package/package.json +1 -1
package/dist/overlay-commands.js
CHANGED
|
@@ -446,7 +446,10 @@ function updateOne(o, git, out, err) {
|
|
|
446
446
|
}
|
|
447
447
|
if (clean === 'dirty') {
|
|
448
448
|
err.write(`overlay "${o.name}": local modifications in ${o.path} — refusing to update. ` +
|
|
449
|
-
`Commit/stash them, or
|
|
449
|
+
`Commit/stash them, or discard them with ` +
|
|
450
|
+
`'canary overlay remove ${o.name} --force' and re-add. ` +
|
|
451
|
+
`(Plain 'overlay remove' refuses on a dirty clone too, so it will not ` +
|
|
452
|
+
`throw the edits away by accident.)\n`);
|
|
450
453
|
return 1;
|
|
451
454
|
}
|
|
452
455
|
if (o.ref) {
|
|
@@ -520,8 +523,15 @@ function update(name, deps = {}) {
|
|
|
520
523
|
/**
|
|
521
524
|
* `canary overlay remove <name>` — deregister an overlay and delete its clone.
|
|
522
525
|
* Unknown name is an error; the registry is left unchanged in that case.
|
|
526
|
+
*
|
|
527
|
+
* Removal is destructive and irreversible, so it takes the same posture as
|
|
528
|
+
* `overlay update` (#675): a clone with local modifications — or one whose git
|
|
529
|
+
* status cannot be read, which is no safer to delete blind — is refused, and
|
|
530
|
+
* `--force` is the explicit opt-in. A clone that is already gone is not a
|
|
531
|
+
* refusal: there is nothing left to lose, so the stale registry row is dropped.
|
|
523
532
|
*/
|
|
524
|
-
function remove(name, deps = {}) {
|
|
533
|
+
function remove(name, deps = {}, opts = {}) {
|
|
534
|
+
const git = deps.git ?? defaultGit;
|
|
525
535
|
const homeDir = deps.homeDir ?? os.homedir();
|
|
526
536
|
const out = deps.out ?? process.stdout;
|
|
527
537
|
const err = deps.err ?? process.stderr;
|
|
@@ -538,6 +548,22 @@ function remove(name, deps = {}) {
|
|
|
538
548
|
err.write(`canary overlay remove: no overlay named "${name}".\n`);
|
|
539
549
|
return 1;
|
|
540
550
|
}
|
|
551
|
+
if (!opts.force && fs.existsSync(entry.path)) {
|
|
552
|
+
const clean = workingTreeStatus(entry.path, git);
|
|
553
|
+
if (clean === 'unreadable') {
|
|
554
|
+
err.write(`canary overlay remove: cannot read git status at ${entry.path} — ` +
|
|
555
|
+
`refusing to delete it. Inspect the directory, then re-run with ` +
|
|
556
|
+
`--force to remove it anyway.\n`);
|
|
557
|
+
return 1;
|
|
558
|
+
}
|
|
559
|
+
if (clean === 'dirty') {
|
|
560
|
+
err.write(`canary overlay remove: local modifications in ${entry.path} — ` +
|
|
561
|
+
`refusing to delete them. Commit or discard the changes ` +
|
|
562
|
+
`(git -C ${entry.path} status), then re-run; or re-run with ` +
|
|
563
|
+
`--force to delete the clone and the changes.\n`);
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
541
567
|
fs.rmSync(entry.path, { recursive: true, force: true });
|
|
542
568
|
const { registry: next } = registry.remove(reg, name);
|
|
543
569
|
registry.write(next, homeDir);
|
package/dist/router.js
CHANGED
|
@@ -48,8 +48,13 @@ exports.route = route;
|
|
|
48
48
|
*/
|
|
49
49
|
const overlay = __importStar(require("./overlay-commands.js"));
|
|
50
50
|
const doctor_js_1 = require("./doctor.js");
|
|
51
|
+
const uninstall_js_1 = require("./uninstall.js");
|
|
51
52
|
/** Subcommands handled in TypeScript rather than forwarded to the binary. */
|
|
52
|
-
exports.TS_COMMANDS = [
|
|
53
|
+
exports.TS_COMMANDS = [
|
|
54
|
+
'overlay',
|
|
55
|
+
'doctor',
|
|
56
|
+
'uninstall',
|
|
57
|
+
];
|
|
53
58
|
/** True when `argv` (process.argv.slice(2)) targets a TS-handled command. */
|
|
54
59
|
function isTsCommand(argv) {
|
|
55
60
|
return argv.length > 0 && exports.TS_COMMANDS.includes(argv[0]);
|
|
@@ -88,7 +93,7 @@ function refFrom(flags) {
|
|
|
88
93
|
return typeof ref === 'string' ? ref : null;
|
|
89
94
|
}
|
|
90
95
|
const OVERLAY_USAGE = 'usage: canary overlay <add|list|lint|update|remove> [args]\n' +
|
|
91
|
-
' add <source> [--ref <tag>] list [--conflicts] lint <name|path> [--json] update [name] remove <name
|
|
96
|
+
' add <source> [--ref <tag>] list [--conflicts] lint <name|path> [--json] update [name] remove <name> [--force]\n';
|
|
92
97
|
/** Overlay subcommand handlers, keyed by name. Each returns a process exit code. */
|
|
93
98
|
const OVERLAY_SUBCOMMANDS = {
|
|
94
99
|
add: ({ positionals, flags, deps, err }) => {
|
|
@@ -103,12 +108,16 @@ const OVERLAY_SUBCOMMANDS = {
|
|
|
103
108
|
list: ({ flags, deps }) => overlay.list(deps, { conflicts: flags.conflicts === true }),
|
|
104
109
|
lint: ({ positionals, flags, deps }) => overlay.lint(positionals[0], deps, { json: flags.json === true }),
|
|
105
110
|
update: ({ positionals, deps }) => overlay.update(positionals[0] ?? null, deps),
|
|
106
|
-
remove: ({ positionals, deps, err }) => {
|
|
111
|
+
remove: ({ positionals, flags, deps, err }) => {
|
|
107
112
|
if (positionals.length < 1) {
|
|
108
|
-
err.write('usage: canary overlay remove <name
|
|
113
|
+
err.write('usage: canary overlay remove <name> [--force]\n');
|
|
109
114
|
return 1;
|
|
110
115
|
}
|
|
111
|
-
|
|
116
|
+
// `--force` deletes a clone with local modifications (#675); without it,
|
|
117
|
+
// removal refuses rather than destroying uncommitted work.
|
|
118
|
+
return overlay.remove(positionals[0], deps, {
|
|
119
|
+
force: flags.force === true,
|
|
120
|
+
});
|
|
112
121
|
},
|
|
113
122
|
};
|
|
114
123
|
function runOverlay(args, deps) {
|
|
@@ -137,5 +146,8 @@ function route(argv, deps = {}) {
|
|
|
137
146
|
if (argv[0] === 'doctor') {
|
|
138
147
|
return (0, doctor_js_1.runDoctor)(argv.slice(1), deps);
|
|
139
148
|
}
|
|
149
|
+
if (argv[0] === 'uninstall') {
|
|
150
|
+
return (0, uninstall_js_1.run)(argv.slice(1), deps);
|
|
151
|
+
}
|
|
140
152
|
return null;
|
|
141
153
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering half of `canary uninstall`. Groups artifacts by disposition and
|
|
3
|
+
* always states the denominator: a scope with nothing in it says so rather
|
|
4
|
+
* than printing an empty list that reads as "all clean".
|
|
5
|
+
*/
|
|
6
|
+
import type { Artifact, Scope } from './uninstall-types.js';
|
|
7
|
+
export declare function render(artifacts: Artifact[], opts: {
|
|
8
|
+
scope: Scope;
|
|
9
|
+
apply: boolean;
|
|
10
|
+
failed?: Artifact[];
|
|
11
|
+
}): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.render = render;
|
|
4
|
+
function human(bytes) {
|
|
5
|
+
if (bytes === undefined || bytes === 0)
|
|
6
|
+
return '';
|
|
7
|
+
const units = ['B', 'K', 'M', 'G'];
|
|
8
|
+
let n = bytes;
|
|
9
|
+
let i = 0;
|
|
10
|
+
while (n >= 1024 && i < units.length - 1) {
|
|
11
|
+
n /= 1024;
|
|
12
|
+
i += 1;
|
|
13
|
+
}
|
|
14
|
+
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)}${units[i]}`;
|
|
15
|
+
}
|
|
16
|
+
function render(artifacts, opts) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const mode = opts.apply ? '(apply)' : '(dry run)';
|
|
19
|
+
lines.push(`canary uninstall --${opts.scope} ${mode}`, '');
|
|
20
|
+
// `disposition` records what the scan decided, not what the removal achieved.
|
|
21
|
+
// A failed rmSync leaves the artifact `removable`, so rendering straight from
|
|
22
|
+
// the disposition would report it as removed — a clean-looking summary over
|
|
23
|
+
// work that did not happen.
|
|
24
|
+
const failedSet = new Set(opts.failed ?? []);
|
|
25
|
+
const removable = artifacts.filter((a) => a.disposition === 'removable' && !failedSet.has(a));
|
|
26
|
+
const failed = artifacts.filter((a) => failedSet.has(a));
|
|
27
|
+
const blocked = artifacts.filter((a) => a.disposition === 'blocked');
|
|
28
|
+
const manual = artifacts.filter((a) => a.disposition === 'manual');
|
|
29
|
+
const section = (title, rows) => {
|
|
30
|
+
if (rows.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
lines.push(` ${title} (${rows.length})`);
|
|
33
|
+
for (const a of rows) {
|
|
34
|
+
const size = human(a.sizeBytes);
|
|
35
|
+
const tail = a.reason ? ` — ${a.reason}` : '';
|
|
36
|
+
lines.push(` ${a.label}${size ? ` ${size}` : ''}${a.path ? `\n ${a.path}` : ''}${tail}`);
|
|
37
|
+
}
|
|
38
|
+
lines.push('');
|
|
39
|
+
};
|
|
40
|
+
section(opts.apply ? 'Removed' : 'Removable', removable);
|
|
41
|
+
section('FAILED to remove', failed);
|
|
42
|
+
section('Skipped', blocked);
|
|
43
|
+
section('You must do these yourself', manual);
|
|
44
|
+
// A zero denominator is an abstention, not a pass: say so explicitly rather
|
|
45
|
+
// than printing an empty list that reads as "all clean".
|
|
46
|
+
if (artifacts.length === 0) {
|
|
47
|
+
lines.push(' Nothing found for this scope — nothing to remove.', '');
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
if (removable.length === 0) {
|
|
51
|
+
lines.push(' Nothing removable for this scope.');
|
|
52
|
+
}
|
|
53
|
+
const summary = ` ${removable.length} ${opts.apply ? 'removed' : 'removable'}, ` +
|
|
54
|
+
(failed.length > 0 ? `${failed.length} FAILED, ` : '') +
|
|
55
|
+
`${blocked.length} skipped, ${manual.length} manual.`;
|
|
56
|
+
lines.push(opts.apply
|
|
57
|
+
? summary
|
|
58
|
+
: `${summary} Nothing was removed — re-run with --apply.`);
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Artifact, type EnumerateOptions, type UninstallDeps } from './uninstall-types.js';
|
|
2
|
+
export declare function enumerate(opts: EnumerateOptions, deps?: UninstallDeps): Artifact[];
|
|
3
|
+
export interface RemovalOutcome {
|
|
4
|
+
removed: Artifact[];
|
|
5
|
+
failed: Array<{
|
|
6
|
+
artifact: Artifact;
|
|
7
|
+
error: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Remove `.canary/` while preserving the quarantine ledger when it was not
|
|
12
|
+
* opted into. Deleting the directory wholesale would take the ledger with it,
|
|
13
|
+
* silently undoing the protection the `blocked` disposition just promised.
|
|
14
|
+
*/
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.enumerate = enumerate;
|
|
37
|
+
/**
|
|
38
|
+
* Enumeration half of `canary uninstall` — walks the install footprint and
|
|
39
|
+
* classifies every artifact it finds. Pure discovery: nothing here writes or
|
|
40
|
+
* deletes, so the dry-run and `--apply` paths read from one source of truth.
|
|
41
|
+
*/
|
|
42
|
+
const fs = __importStar(require("node:fs"));
|
|
43
|
+
const os = __importStar(require("node:os"));
|
|
44
|
+
const path = __importStar(require("node:path"));
|
|
45
|
+
const registry = __importStar(require("./overlays-registry.js"));
|
|
46
|
+
const overlay_commands_js_1 = require("./overlay-commands.js");
|
|
47
|
+
const uninstall_types_js_1 = require("./uninstall-types.js");
|
|
48
|
+
/**
|
|
49
|
+
* Every install path Claude Code currently considers live.
|
|
50
|
+
*
|
|
51
|
+
* Read from `installPath`, NEVER inferred by sorting version numbers: a real
|
|
52
|
+
* machine had 4.0.0, 6.3.0, 6.4.0, 6.5.0, 6.6.0 and 6.7.1 cached with **6.4.0**
|
|
53
|
+
* registered, so a highest-version heuristic would delete the live install and
|
|
54
|
+
* leave five orphans. Every entry counts (a plugin may be registered at more
|
|
55
|
+
* than one scope), so all of them are protected.
|
|
56
|
+
*/
|
|
57
|
+
function registeredInstallPaths(pluginsDir) {
|
|
58
|
+
const manifest = (0, uninstall_types_js_1.readJson)(path.join(pluginsDir, 'installed_plugins.json'));
|
|
59
|
+
const entries = manifest?.plugins?.[uninstall_types_js_1.PLUGIN_KEY];
|
|
60
|
+
const out = new Set();
|
|
61
|
+
if (!Array.isArray(entries))
|
|
62
|
+
return out;
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
const p = e?.installPath;
|
|
65
|
+
if (typeof p === 'string' && p.length > 0)
|
|
66
|
+
out.add(path.resolve(p));
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function enumerateGlobal(opts, deps) {
|
|
71
|
+
const home = deps.homeDir ?? os.homedir();
|
|
72
|
+
const git = deps.git ?? uninstall_types_js_1.defaultGit;
|
|
73
|
+
const found = [];
|
|
74
|
+
const pluginsDir = path.join(home, '.claude', 'plugins');
|
|
75
|
+
const cacheRoot = path.join(pluginsDir, 'cache', uninstall_types_js_1.MARKETPLACE, 'canary');
|
|
76
|
+
const registered = registeredInstallPaths(pluginsDir);
|
|
77
|
+
let versions = [];
|
|
78
|
+
try {
|
|
79
|
+
versions = fs
|
|
80
|
+
.readdirSync(cacheRoot, { withFileTypes: true })
|
|
81
|
+
.filter((d) => d.isDirectory())
|
|
82
|
+
.map((d) => d.name);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
versions = [];
|
|
86
|
+
}
|
|
87
|
+
for (const v of versions.sort()) {
|
|
88
|
+
const dir = path.join(cacheRoot, v);
|
|
89
|
+
const isLive = registered.has(path.resolve(dir));
|
|
90
|
+
found.push({
|
|
91
|
+
scope: 'global',
|
|
92
|
+
kind: 'plugin-cache',
|
|
93
|
+
label: `plugin cache ${v}`,
|
|
94
|
+
path: dir,
|
|
95
|
+
disposition: isLive ? 'blocked' : 'removable',
|
|
96
|
+
reason: isLive
|
|
97
|
+
? 'currently registered — run `/plugin uninstall` first, then re-run'
|
|
98
|
+
: undefined,
|
|
99
|
+
sizeBytes: (0, uninstall_types_js_1.dirSize)(dir),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (registered.size > 0) {
|
|
103
|
+
found.push({
|
|
104
|
+
scope: 'global',
|
|
105
|
+
kind: 'plugin-registration',
|
|
106
|
+
label: 'Claude Code plugin registration',
|
|
107
|
+
path: path.join(pluginsDir, 'installed_plugins.json'),
|
|
108
|
+
disposition: 'manual',
|
|
109
|
+
reason: `/plugin uninstall ${uninstall_types_js_1.PLUGIN_KEY}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const marketplace = path.join(pluginsDir, 'marketplaces', uninstall_types_js_1.MARKETPLACE);
|
|
113
|
+
if (fs.existsSync(marketplace)) {
|
|
114
|
+
found.push({
|
|
115
|
+
scope: 'global',
|
|
116
|
+
kind: 'marketplace',
|
|
117
|
+
label: 'marketplace clone',
|
|
118
|
+
path: marketplace,
|
|
119
|
+
disposition: 'manual',
|
|
120
|
+
reason: `/plugin marketplace remove ${uninstall_types_js_1.MARKETPLACE}`,
|
|
121
|
+
sizeBytes: (0, uninstall_types_js_1.dirSize)(marketplace),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Overlays: skip dirty, remove clean — via the same `workingTreeStatus` the
|
|
125
|
+
// `overlay update`, `overlay remove` (#675), and doctor paths consult, so the
|
|
126
|
+
// removal paths cannot drift apart about whether local edits are safe.
|
|
127
|
+
// A git runner that could not report (`status: null`) maps to a non-zero
|
|
128
|
+
// status, i.e. `unreadable`, which is treated as dirty: a clone whose state
|
|
129
|
+
// cannot be read is not one to sweep away.
|
|
130
|
+
const overlayGit = (args, opts) => {
|
|
131
|
+
const r = git(args, opts);
|
|
132
|
+
return { status: r.status ?? 1, stdout: r.stdout, stderr: r.stderr };
|
|
133
|
+
};
|
|
134
|
+
let overlays = [];
|
|
135
|
+
try {
|
|
136
|
+
overlays = registry.list(registry.read(home));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
overlays = [];
|
|
140
|
+
}
|
|
141
|
+
for (const entry of overlays) {
|
|
142
|
+
const exists = fs.existsSync(entry.path);
|
|
143
|
+
const dirty = exists
|
|
144
|
+
? (0, overlay_commands_js_1.workingTreeStatus)(entry.path, overlayGit) !== 'clean'
|
|
145
|
+
: false;
|
|
146
|
+
found.push({
|
|
147
|
+
scope: 'global',
|
|
148
|
+
kind: 'overlay',
|
|
149
|
+
label: `overlay ${entry.name}`,
|
|
150
|
+
path: entry.path,
|
|
151
|
+
disposition: dirty ? 'blocked' : 'removable',
|
|
152
|
+
reason: dirty
|
|
153
|
+
? 'has local edits — commit or discard them first'
|
|
154
|
+
: undefined,
|
|
155
|
+
sizeBytes: exists ? (0, uninstall_types_js_1.dirSize)(entry.path) : 0,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
found.push({
|
|
159
|
+
scope: 'global',
|
|
160
|
+
kind: 'cli-binary',
|
|
161
|
+
label: 'CLI binary',
|
|
162
|
+
path: deps.cliPath ?? process.argv[1] ?? null,
|
|
163
|
+
disposition: 'manual',
|
|
164
|
+
reason: cliRemovalHint(deps.cliPath ?? process.argv[1] ?? ''),
|
|
165
|
+
});
|
|
166
|
+
return found;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The removal command for however the CLI was installed. Detection is a hint,
|
|
170
|
+
* not a claim — an unrecognised root falls back to naming all of them rather
|
|
171
|
+
* than guessing one.
|
|
172
|
+
*/
|
|
173
|
+
function cliRemovalHint(cliPath) {
|
|
174
|
+
const p = cliPath.toLowerCase();
|
|
175
|
+
if (p.includes('volta'))
|
|
176
|
+
return 'volta uninstall canary-test-cli';
|
|
177
|
+
if (p.includes('mise') || p.includes('.asdf'))
|
|
178
|
+
return 'mise unuse -g npm:canary-test-cli';
|
|
179
|
+
if (p.includes('node_modules'))
|
|
180
|
+
return 'npm uninstall -g canary-test-cli';
|
|
181
|
+
return 'npm uninstall -g canary-test-cli (or volta/mise equivalent)';
|
|
182
|
+
}
|
|
183
|
+
function enumerateProject(opts, deps) {
|
|
184
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
185
|
+
const found = [];
|
|
186
|
+
const includeGenerated = opts.includeGenerated === true;
|
|
187
|
+
const canaryDir = path.join(cwd, '.canary');
|
|
188
|
+
const quarantine = path.join(canaryDir, 'quarantine.json');
|
|
189
|
+
const hasQuarantine = fs.existsSync(quarantine);
|
|
190
|
+
if (fs.existsSync(canaryDir)) {
|
|
191
|
+
found.push({
|
|
192
|
+
scope: 'project',
|
|
193
|
+
kind: 'project-config',
|
|
194
|
+
label: '.canary/ project config',
|
|
195
|
+
path: canaryDir,
|
|
196
|
+
disposition: 'removable',
|
|
197
|
+
sizeBytes: (0, uninstall_types_js_1.dirSize)(canaryDir),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (hasQuarantine) {
|
|
201
|
+
found.push({
|
|
202
|
+
scope: 'project',
|
|
203
|
+
kind: 'quarantine-ledger',
|
|
204
|
+
label: '.canary/quarantine.json (deleted-test ledger)',
|
|
205
|
+
path: quarantine,
|
|
206
|
+
disposition: includeGenerated ? 'removable' : 'blocked',
|
|
207
|
+
reason: includeGenerated
|
|
208
|
+
? undefined
|
|
209
|
+
: `records why tests were deleted — pass --include-generated to remove`,
|
|
210
|
+
sizeBytes: (0, uninstall_types_js_1.dirSize)(quarantine),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const reports = path.join(cwd, 'test-results', 'reports');
|
|
214
|
+
if (fs.existsSync(reports)) {
|
|
215
|
+
found.push({
|
|
216
|
+
scope: 'project',
|
|
217
|
+
kind: 'project-reports',
|
|
218
|
+
label: 'test-results/reports/',
|
|
219
|
+
path: reports,
|
|
220
|
+
disposition: 'removable',
|
|
221
|
+
sizeBytes: (0, uninstall_types_js_1.dirSize)(reports),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const generated = path.join(cwd, 'tests', 'generated');
|
|
225
|
+
if (fs.existsSync(generated)) {
|
|
226
|
+
let count = 0;
|
|
227
|
+
try {
|
|
228
|
+
count = fs.readdirSync(generated).length;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
count = 0;
|
|
232
|
+
}
|
|
233
|
+
found.push({
|
|
234
|
+
scope: 'project',
|
|
235
|
+
kind: 'generated-tests',
|
|
236
|
+
label: `tests/generated/ (${count} file(s))`,
|
|
237
|
+
path: generated,
|
|
238
|
+
disposition: includeGenerated ? 'removable' : 'blocked',
|
|
239
|
+
reason: includeGenerated
|
|
240
|
+
? undefined
|
|
241
|
+
: 'unpromoted generated tests — pass --include-generated to remove',
|
|
242
|
+
sizeBytes: (0, uninstall_types_js_1.dirSize)(generated),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// `.mcp.json` is shared with other servers, so only our key comes out.
|
|
246
|
+
const mcpPath = path.join(cwd, '.mcp.json');
|
|
247
|
+
const mcp = (0, uninstall_types_js_1.readJson)(mcpPath);
|
|
248
|
+
if (mcp?.mcpServers && Object.hasOwn(mcp.mcpServers, uninstall_types_js_1.MCP_KEY)) {
|
|
249
|
+
found.push({
|
|
250
|
+
scope: 'project',
|
|
251
|
+
kind: 'mcp-entry',
|
|
252
|
+
label: `.mcp.json → "${uninstall_types_js_1.MCP_KEY}" server entry`,
|
|
253
|
+
path: mcpPath,
|
|
254
|
+
disposition: 'removable',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return found;
|
|
258
|
+
}
|
|
259
|
+
function enumerate(opts, deps = {}) {
|
|
260
|
+
const out = [];
|
|
261
|
+
if (opts.scope === 'global' || opts.scope === 'all') {
|
|
262
|
+
out.push(...enumerateGlobal(opts, deps));
|
|
263
|
+
}
|
|
264
|
+
if (opts.scope === 'project' || opts.scope === 'all') {
|
|
265
|
+
out.push(...enumerateProject(opts, deps));
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Remove `.canary/` while preserving the quarantine ledger when it was not
|
|
271
|
+
* opted into. Deleting the directory wholesale would take the ledger with it,
|
|
272
|
+
* silently undoing the protection the `blocked` disposition just promised.
|
|
273
|
+
*/
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export declare const MARKETPLACE = "bop-clocktower";
|
|
2
|
+
export declare const PLUGIN_KEY = "canary@bop-clocktower";
|
|
3
|
+
export declare const MCP_KEY = "canary-mcp";
|
|
4
|
+
export type Scope = 'global' | 'project' | 'all';
|
|
5
|
+
/**
|
|
6
|
+
* `removable` — the CLI will delete it under `--apply`.
|
|
7
|
+
* `blocked` — found, deliberately not removed; `reason` says why.
|
|
8
|
+
* `manual` — someone else owns it; `reason` carries the exact command.
|
|
9
|
+
*/
|
|
10
|
+
export type Disposition = 'removable' | 'blocked' | 'manual';
|
|
11
|
+
export interface Artifact {
|
|
12
|
+
scope: 'global' | 'project';
|
|
13
|
+
kind: string;
|
|
14
|
+
label: string;
|
|
15
|
+
path: string | null;
|
|
16
|
+
disposition: Disposition;
|
|
17
|
+
reason?: string;
|
|
18
|
+
sizeBytes?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface EnumerateOptions {
|
|
21
|
+
scope: Scope;
|
|
22
|
+
includeGenerated?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export type GitRunner = (args: string[], opts?: {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
}) => {
|
|
27
|
+
status: number | null;
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
};
|
|
31
|
+
export interface Writer {
|
|
32
|
+
write: (s: string) => void;
|
|
33
|
+
}
|
|
34
|
+
export interface UninstallDeps {
|
|
35
|
+
homeDir?: string;
|
|
36
|
+
cwd?: string;
|
|
37
|
+
out?: Writer;
|
|
38
|
+
err?: Writer;
|
|
39
|
+
git?: GitRunner;
|
|
40
|
+
/** Resolved path of the running CLI, injectable for tests. */
|
|
41
|
+
cliPath?: string;
|
|
42
|
+
}
|
|
43
|
+
export declare const defaultGit: GitRunner;
|
|
44
|
+
export declare function readJson(file: string): unknown;
|
|
45
|
+
/** Recursive byte size; best-effort, never throws. */
|
|
46
|
+
export declare function dirSize(target: string): number;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.defaultGit = exports.MCP_KEY = exports.PLUGIN_KEY = exports.MARKETPLACE = void 0;
|
|
37
|
+
exports.readJson = readJson;
|
|
38
|
+
exports.dirSize = dirSize;
|
|
39
|
+
/**
|
|
40
|
+
* Shared vocabulary for `canary uninstall`: the artifact model every phase
|
|
41
|
+
* speaks, plus the filesystem helpers the scan needs. Split out so the scan,
|
|
42
|
+
* render, and removal modules each stay independently readable.
|
|
43
|
+
*/
|
|
44
|
+
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const path = __importStar(require("node:path"));
|
|
46
|
+
const node_child_process_1 = require("node:child_process");
|
|
47
|
+
exports.MARKETPLACE = 'bop-clocktower';
|
|
48
|
+
exports.PLUGIN_KEY = 'canary@bop-clocktower';
|
|
49
|
+
exports.MCP_KEY = 'canary-mcp';
|
|
50
|
+
const defaultGit = (args, opts = {}) => {
|
|
51
|
+
const r = (0, node_child_process_1.spawnSync)('git', args, { cwd: opts.cwd, encoding: 'utf8' });
|
|
52
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
53
|
+
};
|
|
54
|
+
exports.defaultGit = defaultGit;
|
|
55
|
+
function readJson(file) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Recursive byte size; best-effort, never throws. */
|
|
64
|
+
function dirSize(target) {
|
|
65
|
+
let total = 0;
|
|
66
|
+
const walk = (p) => {
|
|
67
|
+
let st;
|
|
68
|
+
try {
|
|
69
|
+
st = fs.statSync(p);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (st.isDirectory()) {
|
|
75
|
+
let kids = [];
|
|
76
|
+
try {
|
|
77
|
+
kids = fs.readdirSync(p);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const k of kids)
|
|
83
|
+
walk(path.join(p, k));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
total += st.size;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(target);
|
|
90
|
+
return total;
|
|
91
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Artifact, type UninstallDeps } from './uninstall-types.js';
|
|
2
|
+
export { enumerate } from './uninstall-scan.js';
|
|
3
|
+
export { render } from './uninstall-render.js';
|
|
4
|
+
export type { Artifact, Disposition, EnumerateOptions, Scope, UninstallDeps, } from './uninstall-types.js';
|
|
5
|
+
export interface RemovalOutcome {
|
|
6
|
+
removed: Artifact[];
|
|
7
|
+
failed: Array<{
|
|
8
|
+
artifact: Artifact;
|
|
9
|
+
error: string;
|
|
10
|
+
}>;
|
|
11
|
+
}
|
|
12
|
+
export declare function removeAll(artifacts: Artifact[], deps?: UninstallDeps): RemovalOutcome;
|
|
13
|
+
export declare function run(argv: readonly string[], deps?: UninstallDeps): number;
|