@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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Reconciliation engine for catalog packages.
3
+ *
4
+ * S-303: reconcile(catalog, installed, options?) compares the desired catalog
5
+ * state against currently installed packages and returns a ReconcilePlan with
6
+ * install, uninstall, upgrade actions and orphan reports.
7
+ *
8
+ * executeActions(plan, options?) runs the shell commands via piInstall /
9
+ * piUninstall and updates the lock file on success.
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import { createHash } from "node:crypto";
14
+ import { piInstall, piUninstall } from "../util/exec.js";
15
+ import { lockFile } from "../config/paths.js";
16
+ import type { LockFile } from "../config/schema.js";
17
+ import type { InstalledMap } from "./install.js";
18
+ import {
19
+ sourceToKey,
20
+ extractNpmVersion,
21
+ extractVersionFromSource,
22
+ } from "./source.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** A catalog entry as consumed by reconcile. */
29
+ export interface CatalogEntry {
30
+ /** Source string (e.g. "npm:@foo/bar@1.0.0", "git:github.com/user/repo"). */
31
+ source: string;
32
+ /** Whether the package should be managed. Defaults to true when absent. */
33
+ enabled?: boolean;
34
+ }
35
+
36
+ export interface InstallAction {
37
+ type: "install";
38
+ /** Catalog key for the package. */
39
+ key: string;
40
+ source: string;
41
+ }
42
+
43
+ export interface UninstallAction {
44
+ type: "uninstall";
45
+ /** Installed-map key to uninstall. */
46
+ key: string;
47
+ source: string;
48
+ }
49
+
50
+ export interface UpgradeAction {
51
+ type: "upgrade";
52
+ /** Installed-map key. */
53
+ key: string;
54
+ source: string;
55
+ currentVersion?: string;
56
+ targetVersion?: string;
57
+ }
58
+
59
+ export interface OrphanReport {
60
+ /** Installed-map key of the orphan package. */
61
+ key: string;
62
+ source: string;
63
+ version?: string;
64
+ }
65
+
66
+ export interface ReconcilePlan {
67
+ installs: InstallAction[];
68
+ uninstalls: UninstallAction[];
69
+ upgrades: UpgradeAction[];
70
+ orphans: OrphanReport[];
71
+ }
72
+
73
+ export interface ReconcileOptions {
74
+ /** If true, uninstall actions are also generated for orphans. */
75
+ removeOrphans?: boolean;
76
+ }
77
+
78
+ export interface ActionError {
79
+ action: InstallAction | UninstallAction | UpgradeAction;
80
+ error: Error;
81
+ }
82
+
83
+ export interface ExecuteResult {
84
+ success: boolean;
85
+ errors: ActionError[];
86
+ }
87
+
88
+ export interface ExecuteOptions {
89
+ /** Home directory override (for lock file path). */
90
+ home?: string;
91
+ /** Lock file writer override (for testing). Defaults to fs.writeFileSync. */
92
+ lockFileWriter?: (filePath: string, content: string) => void;
93
+ /** If true, skip actual shell execution and lock file writes. Returns success with no errors. */
94
+ dryRun?: boolean;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Plan helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Returns true when the plan contains at least one action. */
102
+ function hasActions(plan: ReconcilePlan): boolean {
103
+ return (
104
+ plan.installs.length > 0 ||
105
+ plan.uninstalls.length > 0 ||
106
+ plan.upgrades.length > 0
107
+ );
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Lock-file helpers
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Produce a deterministic content hash from a source string.
116
+ *
117
+ * NOTE: This hashes the *source specifier*, not the installed file contents.
118
+ * A future milestone should replace this with actual content hashing after
119
+ * extraction.
120
+ */
121
+ function sourceHashForSource(source: string): string {
122
+ return (
123
+ "sha256-" +
124
+ createHash("sha256").update(source).digest("hex").slice(0, 16)
125
+ );
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // reconcile
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Compare the desired catalog state against currently installed packages
134
+ * and produce a plan of actions.
135
+ *
136
+ * @param catalog Map of catalog keys to entries (from cat.yaml).
137
+ * @param installed Map of installed keys to package info (from scanInstalled).
138
+ * @param options Optional flags controlling orphan handling.
139
+ */
140
+ export function reconcile(
141
+ catalog: Record<string, CatalogEntry>,
142
+ installed: InstalledMap,
143
+ options?: ReconcileOptions,
144
+ ): ReconcilePlan {
145
+ const plan: ReconcilePlan = {
146
+ installs: [],
147
+ uninstalls: [],
148
+ upgrades: [],
149
+ orphans: [],
150
+ };
151
+
152
+ // Track which installed keys are referenced by at least one catalog entry
153
+ const matchedInstalledKeys = new Set<string>();
154
+
155
+ // --- Process catalog entries ---
156
+ for (const [catKey, entry] of Object.entries(catalog)) {
157
+ const isEnabled = entry.enabled !== false;
158
+ const installedKey = sourceToKey(entry.source);
159
+ const installedPkg = installed[installedKey];
160
+
161
+ if (installedPkg) {
162
+ matchedInstalledKeys.add(installedKey);
163
+
164
+ if (!isEnabled) {
165
+ // Disabled + installed → uninstall
166
+ plan.uninstalls.push({
167
+ type: "uninstall",
168
+ key: installedKey,
169
+ source: entry.source,
170
+ });
171
+ } else {
172
+ // Enabled + installed → check for upgrade
173
+ const sourcesDiffer = installedPkg.source !== entry.source;
174
+
175
+ if (sourcesDiffer) {
176
+ const isNpm = entry.source.startsWith("npm:");
177
+ const targetVersion = isNpm
178
+ ? extractNpmVersion(entry.source.slice(4))
179
+ : undefined;
180
+ const currentVersion = installedPkg.version;
181
+
182
+ // For npm without version pin, don't upgrade (already installed)
183
+ const isVersionlessNpm = isNpm && targetVersion === undefined;
184
+
185
+ if (!isVersionlessNpm) {
186
+ plan.upgrades.push({
187
+ type: "upgrade",
188
+ key: installedKey,
189
+ source: entry.source,
190
+ currentVersion,
191
+ targetVersion,
192
+ });
193
+ }
194
+ }
195
+ // If sources match → no action needed
196
+ }
197
+ } else {
198
+ // Not installed
199
+ if (isEnabled) {
200
+ plan.installs.push({
201
+ type: "install",
202
+ key: catKey,
203
+ source: entry.source,
204
+ });
205
+ }
206
+ // Not installed + disabled → no action
207
+ }
208
+ }
209
+
210
+ // --- Detect orphans (installed but not in catalog) ---
211
+ for (const [installedKey, installedPkg] of Object.entries(installed)) {
212
+ if (!matchedInstalledKeys.has(installedKey)) {
213
+ plan.orphans.push({
214
+ key: installedKey,
215
+ source: installedPkg.source,
216
+ version: installedPkg.version,
217
+ });
218
+
219
+ if (options?.removeOrphans) {
220
+ plan.uninstalls.push({
221
+ type: "uninstall",
222
+ key: installedKey,
223
+ source: installedPkg.source,
224
+ });
225
+ }
226
+ }
227
+ }
228
+
229
+ return plan;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // executeActions
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export async function executeActions(
237
+ plan: ReconcilePlan,
238
+ options?: ExecuteOptions,
239
+ ): Promise<ExecuteResult> {
240
+ const errors: ActionError[] = [];
241
+
242
+ // --- Dry-run: preview mode, skip execution and lock file write ---
243
+ if (options?.dryRun) {
244
+ return { success: true, errors };
245
+ }
246
+
247
+ // --- Uninstalls first ---
248
+ for (const action of plan.uninstalls) {
249
+ try {
250
+ await piUninstall(action.key);
251
+ } catch (err) {
252
+ errors.push({
253
+ action,
254
+ error: err instanceof Error ? err : new Error(String(err)),
255
+ });
256
+ }
257
+ }
258
+
259
+ // --- Installs ---
260
+ for (const action of plan.installs) {
261
+ try {
262
+ await piInstall(action.source);
263
+ } catch (err) {
264
+ errors.push({
265
+ action,
266
+ error: err instanceof Error ? err : new Error(String(err)),
267
+ });
268
+ }
269
+ }
270
+
271
+ // --- Upgrades (reinstall) ---
272
+ for (const action of plan.upgrades) {
273
+ try {
274
+ await piInstall(action.source);
275
+ } catch (err) {
276
+ errors.push({
277
+ action,
278
+ error: err instanceof Error ? err : new Error(String(err)),
279
+ });
280
+ }
281
+ }
282
+
283
+ const success = errors.length === 0;
284
+
285
+ // Write lock file only on full success and when there were actions
286
+ if (success && hasActions(plan)) {
287
+ const lfPath = lockFile(options?.home);
288
+
289
+ // Read existing lock to preserve entries not touched in this run
290
+ let existingPackages: LockFile["packages"] = {};
291
+ try {
292
+ const raw = fs.readFileSync(lfPath, "utf-8");
293
+ const parsed = JSON.parse(raw);
294
+ if (parsed?.packages && typeof parsed.packages === "object") {
295
+ existingPackages = parsed.packages;
296
+ }
297
+ } catch {
298
+ // No existing lock or malformed — start fresh
299
+ }
300
+
301
+ const lockPackages = { ...existingPackages };
302
+ const now = new Date().toISOString();
303
+
304
+ // Add/update entries for successful installs
305
+ for (const action of plan.installs) {
306
+ lockPackages[action.key] = {
307
+ version: extractVersionFromSource(action.source),
308
+ sourceHash: sourceHashForSource(action.source),
309
+ installedAt: now,
310
+ syncState: "synced",
311
+ };
312
+ }
313
+
314
+ // Add/update entries for successful upgrades
315
+ for (const action of plan.upgrades) {
316
+ lockPackages[action.key] = {
317
+ version: extractVersionFromSource(action.source),
318
+ sourceHash: sourceHashForSource(action.source),
319
+ installedAt: now,
320
+ syncState: "synced",
321
+ };
322
+ }
323
+
324
+ // Remove entries for successful uninstalls
325
+ for (const action of plan.uninstalls) {
326
+ delete lockPackages[action.key];
327
+ }
328
+
329
+ const lockContent =
330
+ JSON.stringify({ packages: lockPackages }, null, 2) + "\n";
331
+
332
+ const writer =
333
+ options?.lockFileWriter ??
334
+ ((p, c) => fs.writeFileSync(p, c, "utf-8"));
335
+ writer(lfPath, lockContent);
336
+ }
337
+
338
+ return { success, errors };
339
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shared source-parsing logic for package source strings.
3
+ *
4
+ * Centralises npm-name extraction, git-name cleaning, and source-to-key
5
+ * derivation so that `install.ts` and `reconcile.ts` don't duplicate the
6
+ * same logic.
7
+ */
8
+
9
+ import path from "node:path";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface ParsedSource {
16
+ /** Identity key for deduplication. */
17
+ name: string;
18
+ /** Source type. */
19
+ type: "npm" | "git" | "local";
20
+ /** For npm: the package name without the npm: prefix. */
21
+ npmName?: string;
22
+ /** For npm with optional version pin: the version after @, or undefined. */
23
+ npmVersion?: string;
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // npm helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Extract the npm package name from "pkg@version" or "@scope/pkg@version".
32
+ */
33
+ export function extractNpmName(spec: string): string {
34
+ // Scoped package: @scope/pkg@version -> @scope/pkg
35
+ if (spec.startsWith("@")) {
36
+ const secondAt = spec.indexOf("@", 1);
37
+ return secondAt === -1 ? spec : spec.slice(0, secondAt);
38
+ }
39
+ // Unscoped: pkg@version -> pkg
40
+ const atIdx = spec.indexOf("@");
41
+ return atIdx === -1 ? spec : spec.slice(0, atIdx);
42
+ }
43
+
44
+ /**
45
+ * Extract version from an npm spec (the part after the last relevant @).
46
+ */
47
+ export function extractNpmVersion(spec: string): string | undefined {
48
+ if (spec.startsWith("@")) {
49
+ const secondAt = spec.indexOf("@", 1);
50
+ return secondAt === -1 ? undefined : spec.slice(secondAt + 1);
51
+ }
52
+ const atIdx = spec.indexOf("@");
53
+ return atIdx === -1 ? undefined : spec.slice(atIdx + 1);
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // git helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Clean a git/URL source into a canonical host/path name.
62
+ *
63
+ * Examples:
64
+ * "github.com/user/repo@v1" -> "github.com/user/repo"
65
+ * "git@github.com:user/repo" -> "github.com/user/repo"
66
+ * "https://github.com/user/repo@v2" -> "github.com/user/repo"
67
+ * "ssh://git@github.com/user/repo" -> "github.com/user/repo"
68
+ */
69
+ export function cleanGitName(raw: string): string {
70
+ let cleaned = raw;
71
+
72
+ // Strip protocol prefix
73
+ cleaned = cleaned.replace(/^(https?:\/\/|ssh:\/\/|git:\/\/)/, "");
74
+ // Strip user@ prefix (e.g. git@)
75
+ cleaned = cleaned.replace(/^[^@/]+@/, "");
76
+ // Convert colon to slash for git@host:path style
77
+ cleaned = cleaned.replace(/:/g, "/");
78
+
79
+ // Strip trailing @ref
80
+ const atIdx = cleaned.lastIndexOf("@");
81
+ if (atIdx !== -1) {
82
+ cleaned = cleaned.slice(0, atIdx);
83
+ }
84
+
85
+ // Strip trailing .git
86
+ if (cleaned.endsWith(".git")) {
87
+ cleaned = cleaned.slice(0, -4);
88
+ }
89
+
90
+ return cleaned;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Composite helpers
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Parse a raw source string into a structured form.
99
+ */
100
+ export function parseSource(raw: string): ParsedSource {
101
+ // npm:pkg@version or npm:pkg
102
+ if (raw.startsWith("npm:")) {
103
+ const rest = raw.slice(4);
104
+ const npmName = extractNpmName(rest);
105
+ const npmVersion = extractNpmVersion(rest);
106
+ return { name: npmName, type: "npm", npmName, npmVersion };
107
+ }
108
+
109
+ // git: shorthand
110
+ if (raw.startsWith("git:")) {
111
+ const rest = raw.slice(4);
112
+ const name = cleanGitName(rest);
113
+ return { name, type: "git" };
114
+ }
115
+
116
+ // HTTPS / SSH protocol URLs treated as git sources
117
+ if (
118
+ raw.startsWith("https://") ||
119
+ raw.startsWith("http://") ||
120
+ raw.startsWith("ssh://") ||
121
+ raw.startsWith("git://")
122
+ ) {
123
+ const name = cleanGitName(raw);
124
+ return { name, type: "git" };
125
+ }
126
+
127
+ // Everything else is a local path — derive name from directory basename
128
+ const name = path.basename(path.resolve(raw));
129
+ return { name, type: "local" };
130
+ }
131
+
132
+ /**
133
+ * Derive the installed-map key from a source string.
134
+ *
135
+ * - npm sources → npm package name (e.g. "@foo/bar")
136
+ * - git sources → cleaned host/path (e.g. "github.com/user/repo")
137
+ * - local paths → the raw source string itself
138
+ */
139
+ export function sourceToKey(source: string): string {
140
+ if (source.startsWith("npm:")) {
141
+ return extractNpmName(source.slice(4));
142
+ }
143
+ if (source.startsWith("git:")) {
144
+ return cleanGitName(source.slice(4));
145
+ }
146
+ if (
147
+ source.startsWith("https://") ||
148
+ source.startsWith("http://") ||
149
+ source.startsWith("ssh://") ||
150
+ source.startsWith("git://")
151
+ ) {
152
+ return cleanGitName(source);
153
+ }
154
+ // Local path — key is the raw source itself (matches scanInstalled behavior)
155
+ return source;
156
+ }
157
+
158
+ /**
159
+ * Extract a version string from a source for lock-file recording.
160
+ *
161
+ * Returns `"unknown"` when the source carries no version/ref information.
162
+ */
163
+ export function extractVersionFromSource(source: string): string {
164
+ if (source.startsWith("npm:")) {
165
+ return extractNpmVersion(source.slice(4)) ?? "unknown";
166
+ }
167
+ if (source.startsWith("git:")) {
168
+ const rest = source.slice(4);
169
+ const atIdx = rest.lastIndexOf("@");
170
+ return atIdx !== -1 ? rest.slice(atIdx + 1) : "unknown";
171
+ }
172
+ return "unknown";
173
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `ct add` subcommand implementation.
3
+ *
4
+ * Adds a new package to the catalog. Supports:
5
+ * - Full args: `ct add <name> <source> [--rating <r>] [--type <t>]`
6
+ * - Git source without `--type`: prompts for type via `ctx.ui.select()`
7
+ * - After adding, runs `pi install` to install the package
8
+ *
9
+ * Uses `addPackage` from `crud.ts` for validation and catalog mutation,
10
+ * and `writeCatalog` / `readCatalog` for persistence.
11
+ */
12
+
13
+ import type { RatingValue } from "../catalog/ratings.js";
14
+ import type { CommandArgs, CommandCtx } from "./types.js";
15
+ import { addPackage } from "../catalog/crud.js";
16
+ import { readCatalog, writeCatalog } from "../config/io.js";
17
+ import { piInstall } from "../util/exec.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Context for `addCommand`, extending the base with `select` for type prompts. */
24
+ export interface AddCtx extends CommandCtx {
25
+ ui: CommandCtx["ui"] & {
26
+ select?: <T>(options: {
27
+ message: string;
28
+ choices: { value: T; label: string }[];
29
+ }) => Promise<T>;
30
+ };
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const VALID_RATINGS: RatingValue[] = ["core", "useful", "debatable"];
38
+
39
+ function isValidRating(value: string): value is RatingValue {
40
+ return VALID_RATINGS.includes(value as RatingValue);
41
+ }
42
+
43
+ function resolveRating(flags: Record<string, true | string>): RatingValue {
44
+ const raw =
45
+ "r" in flags
46
+ ? flags["r"]
47
+ : "rating" in flags
48
+ ? flags["rating"]
49
+ : undefined;
50
+
51
+ if (raw === true || raw === undefined) return "core";
52
+ if (typeof raw === "string" && isValidRating(raw)) return raw;
53
+ return "core";
54
+ }
55
+
56
+ function resolveType(
57
+ flags: Record<string, true | string>,
58
+ ): "skill" | "pi-native" | undefined {
59
+ const raw =
60
+ "s" in flags
61
+ ? flags["s"]
62
+ : "type" in flags
63
+ ? flags["type"]
64
+ : undefined;
65
+
66
+ if (raw === true || raw === undefined) return undefined;
67
+ if (raw === "skill" || raw === "pi-native") return raw;
68
+ return undefined;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // addCommand
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Execute the `ct add` subcommand.
77
+ *
78
+ * Reads the catalog, validates inputs, prompts for type if needed,
79
+ * adds the package, writes the catalog, and runs `pi install`.
80
+ */
81
+ export async function addCommand(args: CommandArgs, ctx: AddCtx): Promise<void> {
82
+ const { positional, flags } = args;
83
+ const name = positional[0];
84
+ const source = positional[1];
85
+
86
+ // --- Validate required args -----------------------------------------------
87
+ if (!name || !source) {
88
+ ctx.ui.notify(
89
+ "Usage: ct add <name> <source> [--rating <core|useful|debatable>] [--type <skill|pi-native>]",
90
+ "error",
91
+ );
92
+ return;
93
+ }
94
+
95
+ const rating = resolveRating(flags);
96
+ let type = resolveType(flags);
97
+
98
+ // --- Read catalog ---------------------------------------------------------
99
+ const catalog = readCatalog(ctx.home);
100
+
101
+ // --- Prompt for type when git source and no explicit type -----------------
102
+ if (source.startsWith("git:") && type === undefined) {
103
+ if (ctx.ui.select) {
104
+ type = await ctx.ui.select<"skill" | "pi-native">({
105
+ message: `Select type for "${name}"`,
106
+ choices: [
107
+ { value: "skill", label: "Skill" },
108
+ { value: "pi-native", label: "Pi-native" },
109
+ ],
110
+ });
111
+ }
112
+ }
113
+
114
+ // --- Add package ----------------------------------------------------------
115
+ try {
116
+ const updated = addPackage(catalog, name, source, rating, type);
117
+ writeCatalog(updated, ctx.home);
118
+ } catch (err: unknown) {
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ ctx.ui.notify(message, "error");
121
+ return;
122
+ }
123
+
124
+ ctx.ui.notify(`Added "${name}" to catalog`, "info");
125
+
126
+ // --- Run pi install -------------------------------------------------------
127
+ try {
128
+ await piInstall(source);
129
+ } catch {
130
+ ctx.ui.notify(
131
+ `Warning: package "${name}" added to catalog but install failed`,
132
+ "warning",
133
+ );
134
+ }
135
+ }