akm-cli 0.0.0 → 0.0.16

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,486 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { fetchWithRetry } from "./common";
7
+ import { asRecord, asString, GITHUB_API_BASE, githubHeaders } from "./github";
8
+ export function parseRegistryRef(rawRef) {
9
+ const ref = rawRef.trim();
10
+ if (!ref)
11
+ throw new Error("Registry ref is required.");
12
+ if (ref.startsWith("npm:")) {
13
+ return parseNpmRef(ref.slice(4), ref);
14
+ }
15
+ if (ref.startsWith("github:")) {
16
+ return parseGithubShorthand(ref.slice(7), ref);
17
+ }
18
+ if (ref.startsWith("git+")) {
19
+ return parseGitUrl(stripGitTransport(ref), ref);
20
+ }
21
+ if (ref.startsWith("file:")) {
22
+ return tryParseLocalRef(fileUriToPath(ref), true);
23
+ }
24
+ if (ref.startsWith("http://") || ref.startsWith("https://")) {
25
+ return parseRemoteUrl(ref);
26
+ }
27
+ const localRef = tryParseLocalRef(ref, isPathLikeRef(ref));
28
+ if (localRef) {
29
+ return localRef;
30
+ }
31
+ if (ref.startsWith("@") || !looksLikeGithubOwnerRepo(ref)) {
32
+ return parseNpmRef(ref, ref);
33
+ }
34
+ return parseGithubShorthand(ref, ref);
35
+ }
36
+ export async function resolveRegistryArtifact(parsed) {
37
+ if (parsed.source === "npm") {
38
+ return resolveNpmArtifact(parsed);
39
+ }
40
+ if (parsed.source === "local") {
41
+ return resolveLocalArtifact(parsed);
42
+ }
43
+ if (parsed.source === "git") {
44
+ return resolveGitArtifact(parsed);
45
+ }
46
+ return resolveGithubArtifact(parsed);
47
+ }
48
+ function parseNpmRef(input, originalRef) {
49
+ const trimmed = input.trim();
50
+ if (!trimmed)
51
+ throw new Error("Invalid npm ref.");
52
+ const parsed = splitNpmNameAndVersion(trimmed);
53
+ validateNpmPackageName(parsed.packageName);
54
+ return {
55
+ source: "npm",
56
+ ref: originalRef,
57
+ id: `npm:${parsed.packageName}`,
58
+ packageName: parsed.packageName,
59
+ requestedVersionOrTag: parsed.requestedVersionOrTag,
60
+ };
61
+ }
62
+ function parseGithubShorthand(input, originalRef) {
63
+ const [repoPart, requestedRef] = splitRefSuffix(input.trim());
64
+ const segments = repoPart.split("/").filter(Boolean);
65
+ if (segments.length !== 2) {
66
+ throw new Error("Invalid GitHub ref. Expected owner/repo or owner/repo#ref.");
67
+ }
68
+ const owner = segments[0];
69
+ const repo = segments[1].replace(/\.git$/i, "");
70
+ if (!owner || !repo) {
71
+ throw new Error("Invalid GitHub ref. Expected owner/repo.");
72
+ }
73
+ return {
74
+ source: "github",
75
+ ref: originalRef,
76
+ id: `github:${owner}/${repo}`,
77
+ owner,
78
+ repo,
79
+ requestedRef,
80
+ };
81
+ }
82
+ function parseRemoteUrl(rawUrl) {
83
+ let url;
84
+ try {
85
+ url = new URL(rawUrl);
86
+ }
87
+ catch {
88
+ throw new Error("Invalid registry URL.");
89
+ }
90
+ if (url.hostname === "github.com") {
91
+ return parseGithubUrl(url, rawUrl);
92
+ }
93
+ return parseGitUrl(rawUrl, rawUrl);
94
+ }
95
+ function parseGithubUrl(url, rawUrl) {
96
+ const segments = url.pathname.split("/").filter(Boolean);
97
+ if (segments.length < 2) {
98
+ throw new Error("Invalid GitHub URL. Expected https://github.com/owner/repo.");
99
+ }
100
+ const owner = segments[0];
101
+ const repo = segments[1].replace(/\.git$/i, "");
102
+ const requestedRef = url.hash ? decodeURIComponent(url.hash.slice(1)) : undefined;
103
+ return {
104
+ source: "github",
105
+ ref: rawUrl,
106
+ id: `github:${owner}/${repo}`,
107
+ owner,
108
+ repo,
109
+ requestedRef,
110
+ };
111
+ }
112
+ function parseGitUrl(input, originalRef) {
113
+ const [urlPart, requestedRef] = splitRefSuffix(input.trim());
114
+ if (!urlPart)
115
+ throw new Error("Invalid git ref. A URL is required.");
116
+ // Normalize the URL for the id (strip .git suffix, fragment)
117
+ const normalized = urlPart.replace(/\.git$/i, "");
118
+ return {
119
+ source: "git",
120
+ ref: originalRef,
121
+ id: `git:${normalized}`,
122
+ url: urlPart,
123
+ requestedRef,
124
+ };
125
+ }
126
+ function tryParseLocalRef(rawRef, explicitPath) {
127
+ const resolvedPath = path.resolve(rawRef);
128
+ let stat;
129
+ try {
130
+ stat = fs.statSync(resolvedPath);
131
+ }
132
+ catch {
133
+ // Explicit paths (./foo, ../bar, /abs) should throw on missing
134
+ if (explicitPath) {
135
+ throw new Error(`Local path not found: ${resolvedPath}`);
136
+ }
137
+ // Bare names that don't exist on disk — let caller fall through to npm/github
138
+ return undefined;
139
+ }
140
+ if (!stat.isDirectory()) {
141
+ if (explicitPath) {
142
+ throw new Error("Local add path must be a directory, but the provided path is not one.");
143
+ }
144
+ // Bare name exists but isn't a directory — not a local ref
145
+ return undefined;
146
+ }
147
+ const repoRoot = findGitRepoRoot(resolvedPath);
148
+ return {
149
+ source: "local",
150
+ ref: rawRef,
151
+ id: `local:${toReadableLocalId(resolvedPath)}`,
152
+ repoRoot,
153
+ sourcePath: resolvedPath,
154
+ };
155
+ }
156
+ function isPathLikeRef(ref) {
157
+ if (ref === "." || ref === "..")
158
+ return true;
159
+ if (path.isAbsolute(ref))
160
+ return true;
161
+ if (ref.startsWith("./") || ref.startsWith("../") || ref.startsWith(".\\") || ref.startsWith("..\\")) {
162
+ return true;
163
+ }
164
+ return ref.includes("/") || ref.includes("\\");
165
+ }
166
+ async function resolveNpmArtifact(parsed) {
167
+ const encodedName = encodeURIComponent(parsed.packageName);
168
+ const metadata = await fetchJson(`https://registry.npmjs.org/${encodedName}`);
169
+ const versions = asRecord(metadata.versions);
170
+ const distTags = asRecord(metadata["dist-tags"]);
171
+ const requested = parsed.requestedVersionOrTag;
172
+ let resolvedVersion;
173
+ if (!requested) {
174
+ resolvedVersion = asString(distTags.latest);
175
+ }
176
+ else if (requested in versions) {
177
+ resolvedVersion = requested;
178
+ }
179
+ else {
180
+ // Try dist-tag first
181
+ resolvedVersion = asString(distTags[requested]);
182
+ // If not a dist-tag, try semver range resolution
183
+ if (!resolvedVersion && isSemverRange(requested)) {
184
+ const versionKeys = Object.keys(versions).filter(isExactSemver);
185
+ resolvedVersion = maxSatisfying(versionKeys, requested);
186
+ }
187
+ }
188
+ if (!resolvedVersion || !(resolvedVersion in versions)) {
189
+ throw new Error(`Unable to resolve npm ref "${parsed.ref}".`);
190
+ }
191
+ const versionMeta = asRecord(versions[resolvedVersion]);
192
+ const dist = asRecord(versionMeta.dist);
193
+ const tarballUrl = asString(dist.tarball);
194
+ if (!tarballUrl) {
195
+ throw new Error(`npm package ${parsed.packageName}@${resolvedVersion} does not expose a tarball URL.`);
196
+ }
197
+ const resolvedRevision = asString(dist.shasum) ?? asString(dist.integrity);
198
+ return {
199
+ id: parsed.id,
200
+ source: parsed.source,
201
+ ref: parsed.ref,
202
+ artifactUrl: tarballUrl,
203
+ resolvedVersion,
204
+ resolvedRevision,
205
+ };
206
+ }
207
+ async function resolveGithubArtifact(parsed) {
208
+ const headers = githubHeaders();
209
+ if (parsed.requestedRef) {
210
+ const commit = await tryFetchJson(`${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/commits/${encodeURIComponent(parsed.requestedRef)}`, headers);
211
+ const resolvedRevision = asString(commit?.sha) ?? parsed.requestedRef;
212
+ return {
213
+ id: parsed.id,
214
+ source: parsed.source,
215
+ ref: parsed.ref,
216
+ artifactUrl: `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/tarball/${encodeURIComponent(parsed.requestedRef)}`,
217
+ resolvedRevision,
218
+ resolvedVersion: parsed.requestedRef,
219
+ };
220
+ }
221
+ const latestRelease = await tryFetchJson(`${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/releases/latest`, headers);
222
+ if (latestRelease) {
223
+ const tarballUrl = asString(latestRelease.tarball_url);
224
+ if (tarballUrl) {
225
+ return {
226
+ id: parsed.id,
227
+ source: parsed.source,
228
+ ref: parsed.ref,
229
+ artifactUrl: tarballUrl,
230
+ resolvedVersion: asString(latestRelease.tag_name),
231
+ resolvedRevision: asString(latestRelease.target_commitish),
232
+ };
233
+ }
234
+ }
235
+ const repoMeta = await fetchJson(`${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}`, headers);
236
+ const defaultBranch = asString(repoMeta.default_branch);
237
+ if (!defaultBranch) {
238
+ throw new Error(`Unable to resolve default branch for ${parsed.owner}/${parsed.repo}.`);
239
+ }
240
+ const commit = await tryFetchJson(`${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/commits/${encodeURIComponent(defaultBranch)}`, headers);
241
+ return {
242
+ id: parsed.id,
243
+ source: parsed.source,
244
+ ref: parsed.ref,
245
+ artifactUrl: `${GITHUB_API_BASE}/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/tarball/${encodeURIComponent(defaultBranch)}`,
246
+ resolvedVersion: defaultBranch,
247
+ resolvedRevision: asString(commit?.sha) ?? defaultBranch,
248
+ };
249
+ }
250
+ async function resolveGitArtifact(parsed) {
251
+ const ref = parsed.requestedRef ?? "HEAD";
252
+ const result = spawnSync("git", ["ls-remote", parsed.url, ref], { encoding: "utf8", timeout: 30_000 });
253
+ let resolvedRevision;
254
+ if (result.status === 0) {
255
+ const firstLine = result.stdout.trim().split(/\r?\n/)[0];
256
+ resolvedRevision = firstLine?.split(/\s/)[0] || undefined;
257
+ }
258
+ return {
259
+ id: parsed.id,
260
+ source: parsed.source,
261
+ ref: parsed.ref,
262
+ artifactUrl: parsed.url,
263
+ resolvedVersion: parsed.requestedRef,
264
+ resolvedRevision,
265
+ };
266
+ }
267
+ async function resolveLocalArtifact(parsed) {
268
+ return {
269
+ id: parsed.id,
270
+ source: parsed.source,
271
+ ref: parsed.ref,
272
+ artifactUrl: pathToFileURL(parsed.sourcePath).toString(),
273
+ resolvedRevision: parsed.repoRoot ? readGitValue(parsed.repoRoot, "rev-parse", "HEAD") : undefined,
274
+ resolvedVersion: parsed.repoRoot ? readGitValue(parsed.repoRoot, "rev-parse", "--abbrev-ref", "HEAD") : undefined,
275
+ };
276
+ }
277
+ function splitNpmNameAndVersion(input) {
278
+ if (input.startsWith("@")) {
279
+ const secondAt = input.indexOf("@", 1);
280
+ if (secondAt > 0) {
281
+ return {
282
+ packageName: input.slice(0, secondAt),
283
+ requestedVersionOrTag: input.slice(secondAt + 1) || undefined,
284
+ };
285
+ }
286
+ return { packageName: input };
287
+ }
288
+ const at = input.lastIndexOf("@");
289
+ if (at > 0) {
290
+ return {
291
+ packageName: input.slice(0, at),
292
+ requestedVersionOrTag: input.slice(at + 1) || undefined,
293
+ };
294
+ }
295
+ return { packageName: input };
296
+ }
297
+ function validateNpmPackageName(name) {
298
+ if (!name)
299
+ throw new Error("Invalid npm package name: name is required.");
300
+ if (name.length > 214)
301
+ throw new Error(`Invalid npm package name: "${name}" exceeds 214 characters.`);
302
+ if (name !== name.toLowerCase() && !name.startsWith("@")) {
303
+ throw new Error(`Invalid npm package name: "${name}" must be lowercase.`);
304
+ }
305
+ if (name.startsWith(".") || name.startsWith("_")) {
306
+ throw new Error(`Invalid npm package name: "${name}" cannot start with . or _.`);
307
+ }
308
+ if (/[~'!()*]/.test(name) ||
309
+ name.includes(" ") ||
310
+ encodeURIComponent(name)
311
+ .replace(/%40/g, "@")
312
+ .replace(/%2[Ff]/g, "/") !== name) {
313
+ throw new Error(`Invalid npm package name: "${name}" contains invalid characters.`);
314
+ }
315
+ }
316
+ function looksLikeGithubOwnerRepo(ref) {
317
+ const [repoPart] = splitRefSuffix(ref);
318
+ const parts = repoPart.split("/").filter(Boolean);
319
+ return parts.length === 2;
320
+ }
321
+ function splitRefSuffix(value) {
322
+ const hash = value.indexOf("#");
323
+ if (hash < 0)
324
+ return [value, undefined];
325
+ return [value.slice(0, hash), value.slice(hash + 1) || undefined];
326
+ }
327
+ /**
328
+ * Strip the `git+` transport prefix from a ref, returning the inner URL.
329
+ * Handles `git+https://...`, `git+ssh://...`, `git+http://...`, etc.
330
+ */
331
+ function stripGitTransport(ref) {
332
+ return ref.slice(4); // strip "git+"
333
+ }
334
+ /**
335
+ * Convert a `file:` URI to a local filesystem path.
336
+ * Supports `file:./relative`, `file:../relative`, and `file:///absolute`.
337
+ */
338
+ function fileUriToPath(ref) {
339
+ const after = ref.slice(5); // strip "file:"
340
+ // file:///absolute/path or file:///C:/path
341
+ if (after.startsWith("///")) {
342
+ return after.slice(2); // keep one leading /
343
+ }
344
+ // file://hostname/path (rare, treat hostname/path as absolute)
345
+ if (after.startsWith("//")) {
346
+ return after.slice(1);
347
+ }
348
+ // file:./relative or file:../relative or file:/absolute
349
+ return after;
350
+ }
351
+ /**
352
+ * Build a human-readable local ID from an absolute path.
353
+ * /home/user/.hyphn/skills → ~/.hyphn/skills
354
+ * /tmp/my-kit → /tmp/my-kit
355
+ */
356
+ function toReadableLocalId(absolutePath) {
357
+ const home = os.homedir();
358
+ if (absolutePath === home)
359
+ return "~";
360
+ if (absolutePath.startsWith(home + path.sep)) {
361
+ return `~/${absolutePath.slice(home.length + 1)}`;
362
+ }
363
+ return absolutePath;
364
+ }
365
+ function findGitRepoRoot(startDir) {
366
+ let current = path.resolve(startDir);
367
+ while (true) {
368
+ if (fs.existsSync(path.join(current, ".git"))) {
369
+ return current;
370
+ }
371
+ const parent = path.dirname(current);
372
+ if (parent === current)
373
+ return undefined;
374
+ current = parent;
375
+ }
376
+ }
377
+ function readGitValue(repoRoot, ...args) {
378
+ const result = spawnSync("git", ["-C", repoRoot, ...args], { encoding: "utf8" });
379
+ if (result.status !== 0)
380
+ return undefined;
381
+ const value = result.stdout.trim();
382
+ return value || undefined;
383
+ }
384
+ function parseSemver(version) {
385
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
386
+ if (!match)
387
+ return undefined;
388
+ return {
389
+ major: parseInt(match[1], 10),
390
+ minor: parseInt(match[2], 10),
391
+ patch: parseInt(match[3], 10),
392
+ prerelease: match[4],
393
+ };
394
+ }
395
+ function isExactSemver(version) {
396
+ return /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.+-]+)?$/.test(version);
397
+ }
398
+ function isSemverRange(input) {
399
+ return /^[~^>=<*]/.test(input) || /^\d+\.(\d+|\*)/.test(input);
400
+ }
401
+ function compareSemver(a, b) {
402
+ if (a.major !== b.major)
403
+ return a.major - b.major;
404
+ if (a.minor !== b.minor)
405
+ return a.minor - b.minor;
406
+ if (a.patch !== b.patch)
407
+ return a.patch - b.patch;
408
+ // Versions with prerelease are lower than release
409
+ if (a.prerelease && !b.prerelease)
410
+ return -1;
411
+ if (!a.prerelease && b.prerelease)
412
+ return 1;
413
+ return 0;
414
+ }
415
+ function semverGte(a, b) {
416
+ return compareSemver(a, b) >= 0;
417
+ }
418
+ function satisfiesRange(version, range) {
419
+ // Skip pre-release versions unless range specifically mentions one
420
+ if (version.prerelease && !range.includes("-"))
421
+ return false;
422
+ // ^1.2.3 — compatible with version: same major, >= minor.patch
423
+ const caretMatch = range.match(/^\^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
424
+ if (caretMatch) {
425
+ const rMajor = parseInt(caretMatch[1], 10);
426
+ const rMinor = parseInt(caretMatch[2], 10);
427
+ const rPatch = parseInt(caretMatch[3], 10);
428
+ if (version.major !== rMajor)
429
+ return false;
430
+ // ^0.x has special behavior: ^0.2.3 means >=0.2.3 <0.3.0
431
+ if (rMajor === 0) {
432
+ if (version.minor !== rMinor)
433
+ return false;
434
+ return version.patch >= rPatch;
435
+ }
436
+ return semverGte(version, { major: rMajor, minor: rMinor, patch: rPatch });
437
+ }
438
+ // ~1.2.3 — same major.minor, patch >= specified
439
+ const tildeMatch = range.match(/^~(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
440
+ if (tildeMatch) {
441
+ const rMajor = parseInt(tildeMatch[1], 10);
442
+ const rMinor = parseInt(tildeMatch[2], 10);
443
+ const rPatch = parseInt(tildeMatch[3], 10);
444
+ return version.major === rMajor && version.minor === rMinor && version.patch >= rPatch;
445
+ }
446
+ // >=1.2.3
447
+ const gteMatch = range.match(/^>=(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
448
+ if (gteMatch) {
449
+ const rMajor = parseInt(gteMatch[1], 10);
450
+ const rMinor = parseInt(gteMatch[2], 10);
451
+ const rPatch = parseInt(gteMatch[3], 10);
452
+ return semverGte(version, { major: rMajor, minor: rMinor, patch: rPatch });
453
+ }
454
+ // * or latest
455
+ if (range === "*" || range === "latest")
456
+ return true;
457
+ return false;
458
+ }
459
+ export function maxSatisfying(versions, range) {
460
+ const candidates = [];
461
+ for (const v of versions) {
462
+ const parsed = parseSemver(v);
463
+ if (!parsed)
464
+ continue;
465
+ if (satisfiesRange(parsed, range)) {
466
+ candidates.push({ version: v, parsed });
467
+ }
468
+ }
469
+ if (candidates.length === 0)
470
+ return undefined;
471
+ candidates.sort((a, b) => compareSemver(b.parsed, a.parsed));
472
+ return candidates[0].version;
473
+ }
474
+ async function fetchJson(url, headers) {
475
+ const response = await fetchWithRetry(url, { headers });
476
+ if (!response.ok) {
477
+ throw new Error(`Request failed (${response.status}) for ${url}`);
478
+ }
479
+ return (await response.json());
480
+ }
481
+ async function tryFetchJson(url, headers) {
482
+ const response = await fetchWithRetry(url, { headers });
483
+ if (!response.ok)
484
+ return null;
485
+ return (await response.json());
486
+ }