@pi-stef/catalog 0.2.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/README.md +205 -0
- package/extensions/catalog.ts +7 -0
- package/package.json +42 -0
- package/src/catalog/crud.ts +143 -0
- package/src/catalog/install.ts +181 -0
- package/src/catalog/ratings.ts +32 -0
- package/src/catalog/reconcile.ts +339 -0
- package/src/catalog/source.ts +173 -0
- package/src/commands/add.ts +135 -0
- package/src/commands/definitions.ts +78 -0
- package/src/commands/diff.ts +158 -0
- package/src/commands/dispatch.ts +102 -0
- package/src/commands/init.ts +127 -0
- package/src/commands/login.ts +105 -0
- package/src/commands/profiles.ts +147 -0
- package/src/commands/remove.ts +90 -0
- package/src/commands/status.ts +142 -0
- package/src/commands/sync.ts +406 -0
- package/src/commands/toggle.ts +147 -0
- package/src/commands/types.ts +38 -0
- package/src/commands/verify.ts +107 -0
- package/src/config/io.ts +82 -0
- package/src/config/paths.ts +44 -0
- package/src/config/schema.ts +87 -0
- package/src/index.ts +94 -0
- package/src/profiles/manager.ts +159 -0
- package/src/register.ts +285 -0
- package/src/sync/auth.ts +109 -0
- package/src/sync/cache.ts +40 -0
- package/src/sync/gist.ts +253 -0
- package/src/sync/pull.ts +76 -0
- package/src/sync/push.ts +78 -0
- package/src/update/pi-update.ts +60 -0
- package/src/update/registry.ts +27 -0
- package/src/update/self-update.ts +60 -0
- package/src/update/semver.ts +38 -0
- package/src/update/types.ts +21 -0
- package/src/update/update-cache.ts +54 -0
- package/src/util/errors.ts +144 -0
- package/src/util/exec.ts +160 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ct sync` subcommand implementation.
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the full sync cycle:
|
|
5
|
+
* 1. Pull remote catalog from gist
|
|
6
|
+
* 2. Reconcile local state with desired state
|
|
7
|
+
* 3. Execute install/uninstall/upgrade actions
|
|
8
|
+
* 4. Push updated catalog + lock to gist
|
|
9
|
+
*
|
|
10
|
+
* Supports `--dry-run` to preview the plan without executing.
|
|
11
|
+
* Uses `--profile` flag to select the sync profile (default: "default").
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CommandArgs, CommandCtx } from "./types.js";
|
|
15
|
+
import type { CatalogYaml, LockFile } from "../config/schema.js";
|
|
16
|
+
import type { InstalledMap } from "../catalog/install.js";
|
|
17
|
+
import { readCatalog, writeCatalog, readLock, writeLock } from "../config/io.js";
|
|
18
|
+
import { pullCatalog } from "../sync/pull.js";
|
|
19
|
+
import { pushCatalog } from "../sync/push.js";
|
|
20
|
+
import { readCachedGistId } from "../sync/cache.js";
|
|
21
|
+
import { scanInstalled } from "../catalog/install.js";
|
|
22
|
+
import { reconcile, executeActions } from "../catalog/reconcile.js";
|
|
23
|
+
import { extractVersionFromSource } from "../catalog/source.js";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Context for `syncCommand`, `pushCommand`, and `pullCommand`. */
|
|
31
|
+
export type SyncCtx = CommandCtx;
|
|
32
|
+
|
|
33
|
+
/** Alias used by push/pull test suite. */
|
|
34
|
+
export type PushPullCtx = CommandCtx;
|
|
35
|
+
|
|
36
|
+
/** Summary of a completed sync for user reporting. */
|
|
37
|
+
interface SyncSummary {
|
|
38
|
+
pulled: boolean;
|
|
39
|
+
/** Number of install/uninstall/upgrade actions. */
|
|
40
|
+
actionCount: number;
|
|
41
|
+
pushed: boolean;
|
|
42
|
+
gistUrl?: string;
|
|
43
|
+
errors: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// buildSyncedLock
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a populated lock file from the catalog and installed state.
|
|
52
|
+
* Used when reconcile returns zero actions to ensure the lock file
|
|
53
|
+
* carries real installed versions and timestamps.
|
|
54
|
+
*/
|
|
55
|
+
function buildSyncedLock(
|
|
56
|
+
catalog: CatalogYaml,
|
|
57
|
+
installed: InstalledMap,
|
|
58
|
+
): LockFile {
|
|
59
|
+
const now = new Date().toISOString();
|
|
60
|
+
const packages: LockFile["packages"] = {};
|
|
61
|
+
|
|
62
|
+
for (const [key, pkg] of Object.entries(catalog.packages)) {
|
|
63
|
+
if (pkg.enabled === false) continue;
|
|
64
|
+
|
|
65
|
+
const sourceHash =
|
|
66
|
+
"sha256-" +
|
|
67
|
+
createHash("sha256").update(pkg.source).digest("hex").slice(0, 16);
|
|
68
|
+
|
|
69
|
+
// Prefer installed version when available
|
|
70
|
+
const installedPkg = Object.values(installed).find(
|
|
71
|
+
(ip) => ip.source === pkg.source,
|
|
72
|
+
);
|
|
73
|
+
const version =
|
|
74
|
+
installedPkg?.version ?? extractVersionFromSource(pkg.source);
|
|
75
|
+
|
|
76
|
+
packages[key] = {
|
|
77
|
+
version: version ?? "unknown",
|
|
78
|
+
sourceHash,
|
|
79
|
+
installedAt: now,
|
|
80
|
+
syncState: "synced",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { packages };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// syncCommand
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Execute the `ct sync` subcommand.
|
|
93
|
+
*
|
|
94
|
+
* Full sync cycle: pull → reconcile → execute → push.
|
|
95
|
+
* With `--dry-run`, shows the plan without executing.
|
|
96
|
+
*/
|
|
97
|
+
export async function syncCommand(
|
|
98
|
+
args: CommandArgs,
|
|
99
|
+
ctx: SyncCtx,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const { flags } = args;
|
|
102
|
+
const dryRun = "dry-run" in flags;
|
|
103
|
+
const force = "force" in flags;
|
|
104
|
+
const noPush = "no-push" in flags;
|
|
105
|
+
const profile = typeof flags["profile"] === "string" ? flags["profile"] : "default";
|
|
106
|
+
|
|
107
|
+
const summary: SyncSummary = {
|
|
108
|
+
pulled: false,
|
|
109
|
+
actionCount: 0,
|
|
110
|
+
pushed: false,
|
|
111
|
+
errors: [],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// --- 1. Pull remote catalog (into memory only) ---------------------------
|
|
115
|
+
let remoteCatalog = false;
|
|
116
|
+
let pulledData: { catalog: CatalogYaml; lock: LockFile } | undefined;
|
|
117
|
+
try {
|
|
118
|
+
pulledData = await pullCatalog(profile, ctx.home);
|
|
119
|
+
remoteCatalog = true;
|
|
120
|
+
summary.pulled = true;
|
|
121
|
+
} catch (err: unknown) {
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
123
|
+
ctx.ui.notify(`Pull failed: ${message}`, "warning");
|
|
124
|
+
summary.errors.push(message);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- 2. Reconcile --------------------------------------------------------
|
|
128
|
+
// Use pulled catalog if available, otherwise read from disk
|
|
129
|
+
const catalog = pulledData ? pulledData.catalog : readCatalog(ctx.home);
|
|
130
|
+
const installed = scanInstalled(ctx.home);
|
|
131
|
+
|
|
132
|
+
// Build catalog entries for reconcile
|
|
133
|
+
const catalogEntries: Record<string, { source: string; enabled?: boolean }> = {};
|
|
134
|
+
for (const [key, pkg] of Object.entries(catalog.packages)) {
|
|
135
|
+
catalogEntries[key] = {
|
|
136
|
+
source: pkg.source,
|
|
137
|
+
enabled: pkg.enabled,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const plan = reconcile(catalogEntries, installed);
|
|
142
|
+
|
|
143
|
+
summary.actionCount =
|
|
144
|
+
plan.installs.length +
|
|
145
|
+
plan.uninstalls.length +
|
|
146
|
+
plan.upgrades.length;
|
|
147
|
+
|
|
148
|
+
// --- 3. Dry-run: show plan and stop --------------------------------------
|
|
149
|
+
if (dryRun) {
|
|
150
|
+
const parts: string[] = ["Dry run — no changes made."];
|
|
151
|
+
if (plan.installs.length > 0) {
|
|
152
|
+
parts.push(`Would install: ${plan.installs.map((a) => a.key).join(", ")}`);
|
|
153
|
+
}
|
|
154
|
+
if (plan.uninstalls.length > 0) {
|
|
155
|
+
parts.push(`Would uninstall: ${plan.uninstalls.map((a) => a.key).join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
if (plan.upgrades.length > 0) {
|
|
158
|
+
parts.push(`Would upgrade: ${plan.upgrades.map((a) => a.key).join(", ")}`);
|
|
159
|
+
}
|
|
160
|
+
if (plan.orphans.length > 0) {
|
|
161
|
+
parts.push(`Orphans: ${plan.orphans.map((o) => o.key).join(", ")}`);
|
|
162
|
+
}
|
|
163
|
+
if (summary.actionCount === 0 && plan.orphans.length === 0) {
|
|
164
|
+
parts.push("No changes needed.");
|
|
165
|
+
}
|
|
166
|
+
ctx.ui.notify(parts.join("\n"), "info");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- 4. Execute actions --------------------------------------------------
|
|
171
|
+
if (summary.actionCount > 0) {
|
|
172
|
+
// Write pulled catalog to disk before executing actions (pull-then-execute)
|
|
173
|
+
if (pulledData) {
|
|
174
|
+
writeCatalog(pulledData.catalog, ctx.home);
|
|
175
|
+
writeLock(pulledData.lock, ctx.home);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const result = await executeActions(plan, { home: ctx.home });
|
|
179
|
+
|
|
180
|
+
for (const { error } of result.errors) {
|
|
181
|
+
ctx.ui.notify(`Action error: ${error.message}`, "warning");
|
|
182
|
+
summary.errors.push(error.message);
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// No actions needed — write catalog (pulled or local) and build/populate lock
|
|
186
|
+
if (pulledData) {
|
|
187
|
+
writeCatalog(pulledData.catalog, ctx.home);
|
|
188
|
+
}
|
|
189
|
+
// Always write a populated lock so "last sync" is accurate
|
|
190
|
+
const syncedLock = buildSyncedLock(catalog, installed);
|
|
191
|
+
writeLock(syncedLock, ctx.home);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- 5. Push if changed --------------------------------------------------
|
|
195
|
+
if (noPush) {
|
|
196
|
+
// --no-push: skip push and report
|
|
197
|
+
if (summary.actionCount > 0 && summary.errors.length === 0) {
|
|
198
|
+
ctx.ui.notify(
|
|
199
|
+
`Synced locally (${summary.actionCount} action(s)). Push skipped (--no-push).`,
|
|
200
|
+
"info",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const hasGist = readCachedGistId(ctx.home) !== undefined;
|
|
207
|
+
const localHasPackages = Object.keys(catalog.packages).length > 0;
|
|
208
|
+
|
|
209
|
+
if (force || summary.actionCount > 0 || (!hasGist && localHasPackages)) {
|
|
210
|
+
try {
|
|
211
|
+
const updatedCatalog = readCatalog(ctx.home);
|
|
212
|
+
const updatedLock = readLock(ctx.home);
|
|
213
|
+
const pushResult = await pushCatalog(
|
|
214
|
+
updatedCatalog,
|
|
215
|
+
updatedLock,
|
|
216
|
+
profile,
|
|
217
|
+
ctx.home,
|
|
218
|
+
);
|
|
219
|
+
summary.pushed = true;
|
|
220
|
+
summary.gistUrl = pushResult.gistUrl;
|
|
221
|
+
} catch (err: unknown) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
+
ctx.ui.notify(`Push failed: ${message}`, "error");
|
|
224
|
+
summary.errors.push(message);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- 6. Report summary ---------------------------------------------------
|
|
229
|
+
if (summary.errors.length > 0 && !summary.pushed && !remoteCatalog) {
|
|
230
|
+
// All errors, no success — first-time message
|
|
231
|
+
if (!hasGist && !localHasPackages) {
|
|
232
|
+
ctx.ui.notify(
|
|
233
|
+
"No remote gist found and local catalog is empty. Use `ct add` to add packages, then `ct sync` to push.",
|
|
234
|
+
"info",
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (summary.actionCount === 0 && summary.errors.length === 0 && !force) {
|
|
241
|
+
ctx.ui.notify("Catalog already up to date.", "info");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Build detailed summary
|
|
246
|
+
const parts: string[] = [];
|
|
247
|
+
if (summary.pulled) {
|
|
248
|
+
parts.push("Pulled remote catalog.");
|
|
249
|
+
}
|
|
250
|
+
if (plan.installs.length > 0) {
|
|
251
|
+
parts.push(`${plan.installs.length} install(s): ${plan.installs.map((a) => a.key).join(", ")}`);
|
|
252
|
+
}
|
|
253
|
+
if (plan.uninstalls.length > 0) {
|
|
254
|
+
parts.push(`${plan.uninstalls.length} uninstall(s): ${plan.uninstalls.map((a) => a.key).join(", ")}`);
|
|
255
|
+
}
|
|
256
|
+
if (plan.upgrades.length > 0) {
|
|
257
|
+
parts.push(`${plan.upgrades.length} upgrade(s): ${plan.upgrades.map((a) => a.key).join(", ")}`);
|
|
258
|
+
}
|
|
259
|
+
if (summary.pushed) {
|
|
260
|
+
parts.push(`Pushed to gist: ${summary.gistUrl}`);
|
|
261
|
+
}
|
|
262
|
+
if (summary.errors.length > 0) {
|
|
263
|
+
parts.push(`${summary.errors.length} error(s) encountered.`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
ctx.ui.notify(`Synced: ${parts.join(" | ")}`, "info");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// pushCommand (ct push)
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Execute the `ct push` subcommand.
|
|
275
|
+
*
|
|
276
|
+
* Reads the local catalog + lock and pushes them to a GitHub Gist.
|
|
277
|
+
* Reports the gist URL on success.
|
|
278
|
+
*/
|
|
279
|
+
export async function pushCommand(
|
|
280
|
+
args: CommandArgs,
|
|
281
|
+
ctx: PushPullCtx,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
const { flags } = args;
|
|
284
|
+
const profile = typeof flags["profile"] === "string" ? flags["profile"] : "default";
|
|
285
|
+
|
|
286
|
+
const catalog = readCatalog(ctx.home);
|
|
287
|
+
const lock = readLock(ctx.home);
|
|
288
|
+
|
|
289
|
+
// --- --dry-run: show what would be pushed without uploading -------------
|
|
290
|
+
if ("dry-run" in flags) {
|
|
291
|
+
const pkgCount = Object.keys(catalog.packages).length;
|
|
292
|
+
ctx.ui.notify(
|
|
293
|
+
`Dry run — would push ${pkgCount} package(s) to gist (profile: ${profile}).`,
|
|
294
|
+
"info",
|
|
295
|
+
);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const result = await pushCatalog(catalog, lock, profile, ctx.home);
|
|
301
|
+
ctx.ui.notify(`Pushed to gist: ${result.gistUrl}`, "info");
|
|
302
|
+
} catch (err: unknown) {
|
|
303
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
304
|
+
ctx.ui.notify(`Push failed: ${message}`, "error");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// pullCommand (ct pull)
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Execute the `ct pull` subcommand.
|
|
314
|
+
*
|
|
315
|
+
* Pulls the remote catalog from gist, writes it locally, then reconciles
|
|
316
|
+
* and executes any needed install/uninstall/upgrade actions.
|
|
317
|
+
*/
|
|
318
|
+
export async function pullCommand(
|
|
319
|
+
args: CommandArgs,
|
|
320
|
+
ctx: PushPullCtx,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
const { flags } = args;
|
|
323
|
+
const profile = typeof flags["profile"] === "string" ? flags["profile"] : "default";
|
|
324
|
+
|
|
325
|
+
let pulledCatalog: CatalogYaml;
|
|
326
|
+
let pulledLock: LockFile;
|
|
327
|
+
|
|
328
|
+
// --- 1. Pull remote catalog ----------------------------------------------
|
|
329
|
+
try {
|
|
330
|
+
const result = await pullCatalog(profile, ctx.home);
|
|
331
|
+
pulledCatalog = result.catalog;
|
|
332
|
+
pulledLock = result.lock;
|
|
333
|
+
} catch (err: unknown) {
|
|
334
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
335
|
+
ctx.ui.notify(`Pull failed: ${message}`, "error");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- Reconcile against the pulled catalog ------------------------------
|
|
340
|
+
const catalogEntries: Record<string, { source: string; enabled?: boolean }> = {};
|
|
341
|
+
for (const [key, pkg] of Object.entries(pulledCatalog.packages)) {
|
|
342
|
+
catalogEntries[key] = {
|
|
343
|
+
source: pkg.source,
|
|
344
|
+
enabled: pkg.enabled,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const installed = scanInstalled(ctx.home);
|
|
349
|
+
const plan = reconcile(catalogEntries, installed);
|
|
350
|
+
|
|
351
|
+
const actionCount =
|
|
352
|
+
plan.installs.length +
|
|
353
|
+
plan.uninstalls.length +
|
|
354
|
+
plan.upgrades.length;
|
|
355
|
+
|
|
356
|
+
// --- --dry-run: show plan without writing or executing -------------------
|
|
357
|
+
if ("dry-run" in flags) {
|
|
358
|
+
if (actionCount === 0) {
|
|
359
|
+
ctx.ui.notify("Dry run — pulled remote catalog. No changes needed.", "info");
|
|
360
|
+
} else {
|
|
361
|
+
const parts: string[] = ["Dry run — pulled remote catalog. Would execute:"];
|
|
362
|
+
if (plan.installs.length > 0) {
|
|
363
|
+
parts.push(`Would install: ${plan.installs.map((a) => a.key).join(", ")}`);
|
|
364
|
+
}
|
|
365
|
+
if (plan.uninstalls.length > 0) {
|
|
366
|
+
parts.push(`Would uninstall: ${plan.uninstalls.map((a) => a.key).join(", ")}`);
|
|
367
|
+
}
|
|
368
|
+
if (plan.upgrades.length > 0) {
|
|
369
|
+
parts.push(`Would upgrade: ${plan.upgrades.map((a) => a.key).join(", ")}`);
|
|
370
|
+
}
|
|
371
|
+
ctx.ui.notify(parts.join(" "), "info");
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- 2. Write pulled catalog locally ------------------------------------
|
|
377
|
+
writeCatalog(pulledCatalog, ctx.home);
|
|
378
|
+
writeLock(pulledLock, ctx.home);
|
|
379
|
+
|
|
380
|
+
// --- 3. Execute actions -------------------------------------------------
|
|
381
|
+
if (actionCount > 0) {
|
|
382
|
+
const result = await executeActions(plan, { home: ctx.home });
|
|
383
|
+
|
|
384
|
+
for (const { error } of result.errors) {
|
|
385
|
+
ctx.ui.notify(`Action error: ${error.message}`, "warning");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Build summary
|
|
389
|
+
const parts: string[] = ["Pulled remote catalog."];
|
|
390
|
+
if (plan.installs.length > 0) {
|
|
391
|
+
parts.push(`${plan.installs.length} install(s): ${plan.installs.map((a) => a.key).join(", ")}`);
|
|
392
|
+
}
|
|
393
|
+
if (plan.uninstalls.length > 0) {
|
|
394
|
+
parts.push(`${plan.uninstalls.length} uninstall(s): ${plan.uninstalls.map((a) => a.key).join(", ")}`);
|
|
395
|
+
}
|
|
396
|
+
if (plan.upgrades.length > 0) {
|
|
397
|
+
parts.push(`${plan.upgrades.length} upgrade(s): ${plan.upgrades.map((a) => a.key).join(", ")}`);
|
|
398
|
+
}
|
|
399
|
+
if (result.errors.length > 0) {
|
|
400
|
+
parts.push(`${result.errors.length} error(s).`);
|
|
401
|
+
}
|
|
402
|
+
ctx.ui.notify(parts.join(" | "), "info");
|
|
403
|
+
} else {
|
|
404
|
+
ctx.ui.notify("Pulled: catalog is up to date.", "info");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ct toggle`, `ct enable`, and `ct disable` subcommand implementations.
|
|
3
|
+
*
|
|
4
|
+
* - `ct toggle <name>` cycles a package's rating through the cycle:
|
|
5
|
+
* core → useful → debatable → disabled → core
|
|
6
|
+
* - `ct enable <name>` sets a disabled package back to its previous rating
|
|
7
|
+
* (or "core" if no previous rating stored). No-op when already enabled.
|
|
8
|
+
* - `ct disable <name>` sets rating to disabled, saves the previous rating,
|
|
9
|
+
* and runs `pi uninstall` to remove the package.
|
|
10
|
+
*
|
|
11
|
+
* All commands read/write `cat.yaml` via `readCatalog` / `writeCatalog`
|
|
12
|
+
* and provide user feedback through `ctx.ui.notify`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { togglePackage, enablePackage, disablePackage } from "../catalog/crud.js";
|
|
16
|
+
import type { CommandArgs, CommandCtx } from "./types.js";
|
|
17
|
+
import { readCatalog, writeCatalog } from "../config/io.js";
|
|
18
|
+
import { piUninstall } from "../util/exec.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Context for toggle/enable/disable commands. Uses the base `CommandCtx`. */
|
|
25
|
+
export type ToggleCtx = CommandCtx;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// toggleCommand
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Execute the `ct toggle` subcommand.
|
|
33
|
+
*
|
|
34
|
+
* Cycles the package's rating through: core → useful → debatable → disabled → core.
|
|
35
|
+
*/
|
|
36
|
+
export async function toggleCommand(
|
|
37
|
+
args: CommandArgs,
|
|
38
|
+
ctx: ToggleCtx,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const name = args.positional[0];
|
|
41
|
+
|
|
42
|
+
if (!name) {
|
|
43
|
+
ctx.ui.notify("Usage: ct toggle <name>", "error");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const catalog = readCatalog(ctx.home);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const updated = togglePackage(catalog, name);
|
|
51
|
+
writeCatalog(updated, ctx.home);
|
|
52
|
+
ctx.ui.notify(
|
|
53
|
+
`Toggled "${name}" to ${updated.packages[name].rating}`,
|
|
54
|
+
"info",
|
|
55
|
+
);
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
58
|
+
ctx.ui.notify(message, "error");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// enableCommand
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Execute the `ct enable` subcommand.
|
|
68
|
+
*
|
|
69
|
+
* Restores a disabled package to its previous rating (or "core").
|
|
70
|
+
* No-op when the package is already enabled.
|
|
71
|
+
*/
|
|
72
|
+
export async function enableCommand(
|
|
73
|
+
args: CommandArgs,
|
|
74
|
+
ctx: ToggleCtx,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const name = args.positional[0];
|
|
77
|
+
|
|
78
|
+
if (!name) {
|
|
79
|
+
ctx.ui.notify("Usage: ct enable <name>", "error");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const catalog = readCatalog(ctx.home);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const updated = enablePackage(catalog, name);
|
|
87
|
+
|
|
88
|
+
// enablePackage returns the same catalog reference when it's a no-op
|
|
89
|
+
if (updated === catalog) {
|
|
90
|
+
ctx.ui.notify(`"${name}" is already enabled`, "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
writeCatalog(updated, ctx.home);
|
|
95
|
+
ctx.ui.notify(
|
|
96
|
+
`Enabled "${name}" (rating: ${updated.packages[name].rating})`,
|
|
97
|
+
"info",
|
|
98
|
+
);
|
|
99
|
+
} catch (err: unknown) {
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
ctx.ui.notify(message, "error");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// disableCommand
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Execute the `ct disable` subcommand.
|
|
111
|
+
*
|
|
112
|
+
* Sets the package rating to "disabled", saves the previous rating for later
|
|
113
|
+
* restoration, and runs `pi uninstall` to remove the package.
|
|
114
|
+
*/
|
|
115
|
+
export async function disableCommand(
|
|
116
|
+
args: CommandArgs,
|
|
117
|
+
ctx: ToggleCtx,
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
const name = args.positional[0];
|
|
120
|
+
|
|
121
|
+
if (!name) {
|
|
122
|
+
ctx.ui.notify("Usage: ct disable <name>", "error");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const catalog = readCatalog(ctx.home);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const updated = disablePackage(catalog, name);
|
|
130
|
+
writeCatalog(updated, ctx.home);
|
|
131
|
+
ctx.ui.notify(`Disabled "${name}"`, "info");
|
|
132
|
+
} catch (err: unknown) {
|
|
133
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
134
|
+
ctx.ui.notify(message, "error");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Run pi uninstall after disabling
|
|
139
|
+
try {
|
|
140
|
+
await piUninstall(name);
|
|
141
|
+
} catch {
|
|
142
|
+
ctx.ui.notify(
|
|
143
|
+
`Warning: "${name}" disabled in catalog but uninstall failed`,
|
|
144
|
+
"warning",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for all catalog subcommand handlers.
|
|
3
|
+
*
|
|
4
|
+
* Every command receives a `CommandArgs` (parsed by the dispatcher) and a
|
|
5
|
+
* `CommandCtx` (provided by the pi extension runtime). Individual commands
|
|
6
|
+
* may extend these base types with extra UI methods (e.g. `select`, `confirm`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Arguments
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Arguments parsed from the command line by the dispatcher. */
|
|
14
|
+
export interface CommandArgs {
|
|
15
|
+
/** Positional (non-flag) arguments, in order of appearance. */
|
|
16
|
+
positional: string[];
|
|
17
|
+
/** Parsed flags. Boolean flags are `true`; key=value flags hold the string
|
|
18
|
+
* value. */
|
|
19
|
+
flags: Record<string, true | string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Context
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Base context provided by the pi extension runtime.
|
|
28
|
+
*
|
|
29
|
+
* Individual commands extend this with additional UI capabilities
|
|
30
|
+
* (e.g. `select`, `confirm`) as needed.
|
|
31
|
+
*/
|
|
32
|
+
export interface CommandCtx {
|
|
33
|
+
ui: {
|
|
34
|
+
notify: (msg: string, type?: "error" | "info" | "warning") => void;
|
|
35
|
+
};
|
|
36
|
+
/** Home directory override (for testing). */
|
|
37
|
+
home?: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ct verify` subcommand implementation.
|
|
3
|
+
*
|
|
4
|
+
* Checks catalog integrity:
|
|
5
|
+
* - All packages have valid source formats (npm: or git: prefix)
|
|
6
|
+
* - Lock file entries match catalog packages
|
|
7
|
+
* - No stale lock entries (packages in lock but not in catalog)
|
|
8
|
+
* - Sync states are all "synced"
|
|
9
|
+
* - Detects orphan installed packages not tracked in catalog
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CommandArgs, CommandCtx } from "./types.js";
|
|
13
|
+
import { readCatalog, readLock } from "../config/io.js";
|
|
14
|
+
import { scanInstalled } from "../catalog/install.js";
|
|
15
|
+
import { isValidSource } from "../catalog/ratings.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** Context for `verifyCommand`. Uses the base `CommandCtx`. */
|
|
22
|
+
export type VerifyCtx = CommandCtx;
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// verifyCommand
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Execute the `ct verify` subcommand.
|
|
30
|
+
*
|
|
31
|
+
* Performs integrity checks on the catalog, lock file, and installed state.
|
|
32
|
+
* Reports warnings for any issues found; reports success when all checks pass.
|
|
33
|
+
*/
|
|
34
|
+
export async function verifyCommand(
|
|
35
|
+
_args: CommandArgs,
|
|
36
|
+
ctx: VerifyCtx,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const catalog = readCatalog(ctx.home);
|
|
39
|
+
const lock = readLock(ctx.home);
|
|
40
|
+
const installed = scanInstalled(ctx.home);
|
|
41
|
+
|
|
42
|
+
const packages = catalog.packages;
|
|
43
|
+
const issues: string[] = [];
|
|
44
|
+
|
|
45
|
+
// --- 1. Check source formats ---
|
|
46
|
+
for (const [key, pkg] of Object.entries(packages)) {
|
|
47
|
+
if (!isValidSource(pkg.source)) {
|
|
48
|
+
issues.push(`Package "${key}" has invalid source: "${pkg.source}"`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- 2. Check catalog packages are present in lock (skip when no lock) ---
|
|
53
|
+
const hasLock = Object.keys(lock.packages).length > 0;
|
|
54
|
+
if (hasLock) {
|
|
55
|
+
for (const key of Object.keys(packages)) {
|
|
56
|
+
if (!(key in lock.packages)) {
|
|
57
|
+
issues.push(`Package "${key}" missing from lock file`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- 3. Check for stale lock entries ---
|
|
62
|
+
for (const key of Object.keys(lock.packages)) {
|
|
63
|
+
if (!(key in packages)) {
|
|
64
|
+
issues.push(`Lock entry "${key}" not in catalog`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- 4. Check sync states ---
|
|
69
|
+
for (const [key, lockPkg] of Object.entries(lock.packages)) {
|
|
70
|
+
if (lockPkg.syncState !== "synced") {
|
|
71
|
+
issues.push(
|
|
72
|
+
`Package "${key}" has sync state "${lockPkg.syncState}" (expected "synced")`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- 5. Check for orphan installed packages ---
|
|
79
|
+
const catalogSources = new Set<string>();
|
|
80
|
+
for (const pkg of Object.values(packages)) {
|
|
81
|
+
catalogSources.add(pkg.source);
|
|
82
|
+
}
|
|
83
|
+
for (const [key, inst] of Object.entries(installed)) {
|
|
84
|
+
if (!catalogSources.has(inst.source)) {
|
|
85
|
+
issues.push(`Installed package "${key}" is an orphan (not in catalog)`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- Report ---
|
|
90
|
+
const totalPackages = Object.keys(packages).length;
|
|
91
|
+
|
|
92
|
+
if (issues.length === 0) {
|
|
93
|
+
ctx.ui.notify(
|
|
94
|
+
`All checks passed. ${totalPackages} package(s) verified.`,
|
|
95
|
+
"info",
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
// Report each issue as a warning
|
|
99
|
+
for (const issue of issues) {
|
|
100
|
+
ctx.ui.notify(issue, "warning");
|
|
101
|
+
}
|
|
102
|
+
ctx.ui.notify(
|
|
103
|
+
`Verification complete: ${totalPackages} package(s) checked, ${issues.length} issue(s) found.`,
|
|
104
|
+
"info",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|