skillshelf 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/rm.test.ts +24 -0
- package/src/commands/rm.ts +71 -52
package/package.json
CHANGED
package/src/commands/rm.test.ts
CHANGED
|
@@ -166,6 +166,30 @@ describe("skl rm/retire/unretire — removal lifecycle (friction #1)", () => {
|
|
|
166
166
|
expect(errors.join("\n")).toContain("not in the library");
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
test("rm removes MULTIPLE names in one call (regression: batch must not drop names)", async () => {
|
|
170
|
+
await writeSkill(library, "beta");
|
|
171
|
+
await writeSkill(library, "gamma");
|
|
172
|
+
const { ctx } = makeCtx(library);
|
|
173
|
+
// Live OWNED skills need --force; the WHOLE batch must purge, not just the first
|
|
174
|
+
// (the old single-name rm silently ignored names 2..N).
|
|
175
|
+
const code = await rmRun(["alpha", "beta", "gamma", "--force"], ctx);
|
|
176
|
+
expect(code).toBe(0);
|
|
177
|
+
for (const n of ["alpha", "beta", "gamma"]) {
|
|
178
|
+
expect(existsSync(join(library, n))).toBe(false);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("rm batch is ATOMIC on validation — one missing name deletes nothing", async () => {
|
|
183
|
+
await writeSkill(library, "beta");
|
|
184
|
+
const { ctx, errors } = makeCtx(library);
|
|
185
|
+
const code = await rmRun(["alpha", "ghost", "beta", "--force"], ctx);
|
|
186
|
+
expect(code).toBe(1);
|
|
187
|
+
expect(errors.join("\n")).toContain("not in the library");
|
|
188
|
+
// No partial purge — both valid names survive because one name was invalid.
|
|
189
|
+
expect(existsSync(join(library, "alpha"))).toBe(true);
|
|
190
|
+
expect(existsSync(join(library, "beta"))).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
169
193
|
test("rm refuses a path-traversal name (no escape outside the library)", async () => {
|
|
170
194
|
// a sibling dir outside the library that must NOT be deletable via `../`
|
|
171
195
|
const victim = join(tmp, "victim");
|
package/src/commands/rm.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
// `skl rm <name
|
|
2
|
-
// a LINKED entry, just the symlink — the dev repo it points at is never touched, so
|
|
3
|
-
// this doubles as `unlink`) AND drop its taxonomy + lockfile entries, then re-index.
|
|
1
|
+
// `skl rm <name>...` — delete skill(s) from the library entirely: remove each dir (or,
|
|
2
|
+
// for a LINKED entry, just the symlink — the dev repo it points at is never touched, so
|
|
3
|
+
// this doubles as `unlink`) AND drop its taxonomy + lockfile entries, then re-index ONCE.
|
|
4
4
|
// Before this, the ONLY delete path was an unguarded `rm -rf` against the library —
|
|
5
5
|
// no safety rail for the most destructive lifecycle step.
|
|
6
6
|
//
|
|
7
|
-
// skl rm <name
|
|
7
|
+
// skl rm <name>... [--force] [--dry-run] [--json]
|
|
8
|
+
//
|
|
9
|
+
// Batch: multiple names delete in one call (reindex collapses to one, mirroring
|
|
10
|
+
// `retire`). Validation is ATOMIC — if any name is missing or a live OWNED skill without
|
|
11
|
+
// --force, the whole batch refuses and nothing is deleted (never a partial purge).
|
|
8
12
|
//
|
|
9
13
|
// Safety (mirrors `link --at` refuse-by-default + named escape hatch): a LIVE (active,
|
|
10
14
|
// non-retired) skill is refused without --force — retire it first (reversible) or pass
|
|
@@ -12,15 +16,15 @@
|
|
|
12
16
|
// it purges without --force. --dry-run previews exactly what would be removed.
|
|
13
17
|
|
|
14
18
|
import type { Ctx } from "../types.ts";
|
|
15
|
-
import { locateEntry, removeSkill, reindexLibrary } from "../core/lifecycle.ts";
|
|
19
|
+
import { locateEntry, removeSkill, reindexLibrary, type RemoveResult } from "../core/lifecycle.ts";
|
|
16
20
|
import { readTaxonomy } from "../core/taxonomy.ts";
|
|
17
21
|
import { readLockfile } from "../core/provenance.ts";
|
|
18
22
|
import { render, type CommandResult } from "../core/report.ts";
|
|
19
23
|
|
|
20
24
|
export const meta = {
|
|
21
25
|
name: "rm",
|
|
22
|
-
summary: "Delete
|
|
23
|
-
usage: "skl rm <name
|
|
26
|
+
summary: "Delete skill(s) from the library (dir/symlink + taxonomy + lock), re-index",
|
|
27
|
+
usage: "skl rm <name>... [--force] [--dry-run] [--json]",
|
|
24
28
|
} as const;
|
|
25
29
|
|
|
26
30
|
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
@@ -35,64 +39,71 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
35
39
|
ctx.error(`usage: ${meta.usage}`);
|
|
36
40
|
return 1;
|
|
37
41
|
}
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
42
|
+
const names = argv.filter((a) => !a.startsWith("--"));
|
|
43
|
+
if (names.length === 0) {
|
|
40
44
|
ctx.error("skl rm: a <name> is required");
|
|
41
45
|
ctx.error(`usage: ${meta.usage}`);
|
|
42
46
|
return 1;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
// Locate + guard EVERY name up front. The batch is ATOMIC on validation: if any
|
|
51
|
+
// name is missing, or is a live OWNED skill without --force (destroys real bytes),
|
|
52
|
+
// refuse the WHOLE batch and delete nothing — never a partial purge.
|
|
53
|
+
const located = names.map((name) => ({ name, loc: locateEntry(ctx.libraryPath, name) }));
|
|
54
|
+
|
|
55
|
+
const notFound = located.filter((x) => !x.loc.path).map((x) => x.name);
|
|
56
|
+
if (notFound.length) {
|
|
57
|
+
// Not-found stays a plain error (no --json body), matching the prior single-name path.
|
|
58
|
+
for (const n of notFound) ctx.error(`skl rm: '${n}' is not in the library`);
|
|
49
59
|
return 1;
|
|
50
60
|
}
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (loc.active && !loc.isLink && !force) {
|
|
60
|
-
// Refusal is an ERROR path: the human side prints via ctx.error (not ctx.log),
|
|
61
|
-
// so it stays in run(); only the --json payload routes through render().
|
|
61
|
+
|
|
62
|
+
// Refuse only a live OWNED skill unless forced — that destroys real bytes. A LINKED
|
|
63
|
+
// entry is just a symlink (removing it loses nothing); a purely-retired skill is
|
|
64
|
+
// already in the reversible holding area — both purge freely. Gate on `loc.active`
|
|
65
|
+
// itself (NOT `!loc.retired`) so a skill present in BOTH active and _retired keeps
|
|
66
|
+
// the guard on its live copy.
|
|
67
|
+
const refused = located.filter((x) => x.loc.active && !x.loc.isLink && !force).map((x) => x.name);
|
|
68
|
+
if (refused.length) {
|
|
62
69
|
if (json) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
// Preserve the single-name payload shape; use a list form for a batch.
|
|
71
|
+
const body =
|
|
72
|
+
names.length === 1
|
|
73
|
+
? { ok: false, name: names[0], refused: true, reason: "live-owned-needs-force" }
|
|
74
|
+
: { ok: false, refused, reason: "live-owned-needs-force" };
|
|
75
|
+
render(ctx, json, { json: body, human: () => {} });
|
|
68
76
|
} else {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
for (const n of refused) {
|
|
78
|
+
ctx.error(`skl rm: '${n}' is a live skill — this destroys real bytes.`);
|
|
79
|
+
ctx.error("Retire it first (reversible): skl retire " + n);
|
|
80
|
+
ctx.error("Or hard-purge now: skl rm " + n + " --force");
|
|
81
|
+
}
|
|
72
82
|
}
|
|
73
83
|
return 1;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
// Preview what would be dropped (for --dry-run and richer reporting).
|
|
77
|
-
const tax = await readTaxonomy(ctx.libraryPath);
|
|
78
|
-
const lock = await readLockfile(ctx.libraryPath);
|
|
79
|
-
const plan = {
|
|
80
|
-
name,
|
|
81
|
-
path: loc.path,
|
|
82
|
-
mode: loc.isLink ? ("linked" as const) : ("owned" as const),
|
|
83
|
-
wasRetired: loc.retired && !loc.active,
|
|
84
|
-
taxonomyEntry: name in tax.skills,
|
|
85
|
-
lockEntry: name in lock.entries,
|
|
86
|
-
};
|
|
87
|
-
|
|
88
87
|
if (dryRun) {
|
|
88
|
+
const tax = await readTaxonomy(ctx.libraryPath);
|
|
89
|
+
const lock = await readLockfile(ctx.libraryPath);
|
|
90
|
+
const plans = located.map(({ name, loc }) => ({
|
|
91
|
+
name,
|
|
92
|
+
path: loc.path,
|
|
93
|
+
mode: loc.isLink ? ("linked" as const) : ("owned" as const),
|
|
94
|
+
wasRetired: loc.retired && !loc.active,
|
|
95
|
+
taxonomyEntry: name in tax.skills,
|
|
96
|
+
lockEntry: name in lock.entries,
|
|
97
|
+
}));
|
|
89
98
|
const result: CommandResult = {
|
|
90
|
-
json: { ok: true, dryRun: true, plan },
|
|
99
|
+
json: names.length === 1 ? { ok: true, dryRun: true, plan: plans[0] } : { ok: true, dryRun: true, plans },
|
|
91
100
|
human: (emit) => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
for (const plan of plans) {
|
|
102
|
+
emit(`DRY RUN — would remove '${plan.name}':`);
|
|
103
|
+
emit(` path: ${plan.path}${plan.mode === "linked" ? " (symlink only — dev repo untouched)" : ""}`);
|
|
104
|
+
emit(` taxonomy: ${plan.taxonomyEntry ? "drop entry" : "none"}`);
|
|
105
|
+
emit(` lockfile: ${plan.lockEntry ? "drop entry" : "none"}`);
|
|
106
|
+
}
|
|
96
107
|
emit("Re-run without --dry-run to apply.");
|
|
97
108
|
},
|
|
98
109
|
};
|
|
@@ -100,14 +111,22 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
|
100
111
|
return 0;
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
|
|
114
|
+
// Apply: remove each, then re-index ONCE at the end (a per-name reindex is the cost
|
|
115
|
+
// a bulk rm avoids — collapse it to one, mirroring bulkLifecycle).
|
|
116
|
+
const removed: RemoveResult[] = [];
|
|
117
|
+
for (const { name } of located) {
|
|
118
|
+
removed.push(await removeSkill(ctx.libraryPath, name));
|
|
119
|
+
}
|
|
104
120
|
await reindexLibrary(ctx.libraryPath);
|
|
121
|
+
|
|
105
122
|
const result: CommandResult = {
|
|
106
|
-
json: { ok: true, ...removed },
|
|
123
|
+
json: names.length === 1 ? { ok: true, ...removed[0] } : { ok: true, removed },
|
|
107
124
|
human: (emit) => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
for (const r of removed) {
|
|
126
|
+
emit(`removed ${r.name}${r.wasLink ? " (symlink only — dev repo untouched)" : ""}`);
|
|
127
|
+
if (r.taxonomyDropped) emit(" dropped taxonomy entry");
|
|
128
|
+
if (r.lockDropped) emit(" dropped lockfile entry");
|
|
129
|
+
}
|
|
111
130
|
},
|
|
112
131
|
};
|
|
113
132
|
render(ctx, json, result);
|