@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,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
|
+
}
|