akm-cli 0.5.0 → 0.6.0-rc2
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/CHANGELOG.md +53 -5
- package/README.md +9 -9
- package/dist/cli.js +379 -1448
- package/dist/{completions.js → commands/completions.js} +1 -1
- package/dist/{config-cli.js → commands/config-cli.js} +109 -11
- package/dist/commands/curate.js +263 -0
- package/dist/{info.js → commands/info.js} +17 -11
- package/dist/{init.js → commands/init.js} +4 -4
- package/dist/{install-audit.js → commands/install-audit.js} +14 -2
- package/dist/{installed-kits.js → commands/installed-stashes.js} +122 -50
- package/dist/commands/migration-help.js +141 -0
- package/dist/{registry-search.js → commands/registry-search.js} +68 -9
- package/dist/commands/remember.js +178 -0
- package/dist/{stash-search.js → commands/search.js} +28 -69
- package/dist/{self-update.js → commands/self-update.js} +3 -3
- package/dist/{stash-show.js → commands/show.js} +106 -81
- package/dist/{stash-add.js → commands/source-add.js} +133 -67
- package/dist/{stash-clone.js → commands/source-clone.js} +15 -13
- package/dist/{stash-source-manage.js → commands/source-manage.js} +24 -24
- package/dist/{vault.js → commands/vault.js} +43 -0
- package/dist/{stash-ref.js → core/asset-ref.js} +4 -4
- package/dist/{asset-registry.js → core/asset-registry.js} +30 -6
- package/dist/{asset-spec.js → core/asset-spec.js} +13 -6
- package/dist/{common.js → core/common.js} +147 -50
- package/dist/{config.js → core/config.js} +288 -29
- package/dist/core/errors.js +90 -0
- package/dist/{frontmatter.js → core/frontmatter.js} +64 -8
- package/dist/{paths.js → core/paths.js} +4 -4
- package/dist/core/write-source.js +280 -0
- package/dist/{local-search.js → indexer/db-search.js} +49 -32
- package/dist/{db.js → indexer/db.js} +210 -81
- package/dist/{file-context.js → indexer/file-context.js} +3 -3
- package/dist/{indexer.js → indexer/indexer.js} +153 -30
- package/dist/{manifest.js → indexer/manifest.js} +10 -10
- package/dist/{matchers.js → indexer/matchers.js} +4 -7
- package/dist/{metadata.js → indexer/metadata.js} +9 -5
- package/dist/{search-source.js → indexer/search-source.js} +97 -55
- package/dist/{semantic-status.js → indexer/semantic-status.js} +2 -2
- package/dist/{walker.js → indexer/walker.js} +1 -1
- package/dist/{lockfile.js → integrations/lockfile.js} +29 -2
- package/dist/{llm.js → llm/client.js} +12 -48
- package/dist/llm/embedder.js +127 -0
- package/dist/llm/embedders/cache.js +47 -0
- package/dist/llm/embedders/local.js +152 -0
- package/dist/llm/embedders/remote.js +121 -0
- package/dist/llm/embedders/types.js +39 -0
- package/dist/llm/metadata-enhance.js +53 -0
- package/dist/output/cli-hints.js +301 -0
- package/dist/output/context.js +95 -0
- package/dist/{renderers.js → output/renderers.js} +57 -61
- package/dist/output/shapes.js +212 -0
- package/dist/output/text.js +520 -0
- package/dist/{registry-build-index.js → registry/build-index.js} +48 -32
- package/dist/{create-provider-registry.js → registry/create-provider-registry.js} +6 -2
- package/dist/registry/factory.js +33 -0
- package/dist/{origin-resolve.js → registry/origin-resolve.js} +1 -1
- package/dist/registry/providers/index.js +11 -0
- package/dist/{providers → registry/providers}/skills-sh.js +60 -4
- package/dist/{providers → registry/providers}/static-index.js +126 -56
- package/dist/registry/providers/types.js +25 -0
- package/dist/{registry-resolve.js → registry/resolve.js} +10 -6
- package/dist/{detect.js → setup/detect.js} +0 -27
- package/dist/{ripgrep-install.js → setup/ripgrep-install.js} +1 -1
- package/dist/{ripgrep-resolve.js → setup/ripgrep-resolve.js} +2 -2
- package/dist/{setup.js → setup/setup.js} +162 -129
- package/dist/setup/steps.js +45 -0
- package/dist/{kit-include.js → sources/include.js} +1 -1
- package/dist/sources/provider-factory.js +36 -0
- package/dist/sources/provider.js +21 -0
- package/dist/sources/providers/filesystem.js +35 -0
- package/dist/{stash-providers → sources/providers}/git.js +218 -28
- package/dist/{stash-providers → sources/providers}/index.js +4 -4
- package/dist/sources/providers/install-types.js +14 -0
- package/dist/sources/providers/npm.js +160 -0
- package/dist/sources/providers/provider-utils.js +173 -0
- package/dist/sources/providers/sync-from-ref.js +45 -0
- package/dist/sources/providers/tar-utils.js +154 -0
- package/dist/{stash-providers → sources/providers}/website.js +60 -20
- package/dist/{stash-resolve.js → sources/resolve.js} +13 -12
- package/dist/{wiki.js → wiki/wiki.js} +18 -17
- package/dist/{workflow-authoring.js → workflows/authoring.js} +48 -17
- package/dist/{workflow-cli.js → workflows/cli.js} +2 -1
- package/dist/{workflow-db.js → workflows/db.js} +1 -1
- package/dist/workflows/document-cache.js +20 -0
- package/dist/workflows/parser.js +379 -0
- package/dist/workflows/renderer.js +78 -0
- package/dist/{workflow-runs.js → workflows/runs.js} +84 -30
- package/dist/workflows/schema.js +11 -0
- package/dist/workflows/validator.js +48 -0
- package/docs/README.md +30 -0
- package/docs/migration/release-notes/0.0.13.md +4 -0
- package/docs/migration/release-notes/0.1.0.md +6 -0
- package/docs/migration/release-notes/0.2.0.md +6 -0
- package/docs/migration/release-notes/0.3.0.md +5 -0
- package/docs/migration/release-notes/0.5.0.md +6 -0
- package/docs/migration/release-notes/0.6.0.md +75 -0
- package/docs/migration/release-notes/README.md +21 -0
- package/package.json +3 -2
- package/dist/embedder.js +0 -351
- package/dist/errors.js +0 -34
- package/dist/migration-help.js +0 -110
- package/dist/registry-factory.js +0 -19
- package/dist/registry-install.js +0 -532
- package/dist/ripgrep.js +0 -2
- package/dist/stash-provider-factory.js +0 -35
- package/dist/stash-provider.js +0 -1
- package/dist/stash-providers/filesystem.js +0 -41
- package/dist/stash-providers/openviking.js +0 -348
- package/dist/stash-providers/provider-utils.js +0 -11
- package/dist/stash-types.js +0 -1
- package/dist/workflow-markdown.js +0 -251
- /package/dist/{markdown.js → core/markdown.js} +0 -0
- /package/dist/{warn.js → core/warn.js} +0 -0
- /package/dist/{search-fields.js → indexer/search-fields.js} +0 -0
- /package/dist/{usage-events.js → indexer/usage-events.js} +0 -0
- /package/dist/{github.js → integrations/github.js} +0 -0
- /package/dist/{registry-provider.js → registry/types.js} +0 -0
- /package/dist/{registry-types.js → sources/types.js} +0 -0
|
@@ -2,45 +2,95 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { resolveStashDir } from "
|
|
6
|
-
import { loadConfig } from "
|
|
7
|
-
import { ConfigError, UsageError } from "
|
|
8
|
-
import { getRegistryIndexCacheDir } from "
|
|
9
|
-
import { validateGitUrl } from "
|
|
10
|
-
import {
|
|
11
|
-
import { isExpired, sanitizeString } from "./provider-utils";
|
|
5
|
+
import { resolveStashDir } from "../../core/common";
|
|
6
|
+
import { loadConfig } from "../../core/config";
|
|
7
|
+
import { ConfigError, UsageError } from "../../core/errors";
|
|
8
|
+
import { getRegistryCacheDir, getRegistryIndexCacheDir } from "../../core/paths";
|
|
9
|
+
import { parseRegistryRef, resolveRegistryArtifact, validateGitRef, validateGitUrl } from "../../registry/resolve";
|
|
10
|
+
import { registerSourceProvider } from "../provider-factory";
|
|
11
|
+
import { applyAkmIncludeConfig, buildInstallCacheDir, copyDirectoryContents, detectStashRoot, isDirectory, isExpired, sanitizeString, } from "./provider-utils";
|
|
12
12
|
/** Cache TTL before refreshing the mirrored repo (12 hours). */
|
|
13
13
|
const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
14
14
|
/** Maximum stale age allowed when refresh fails (7 days). */
|
|
15
15
|
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
-
const GIT_STASH_TYPES = new Set(["git"
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const GIT_STASH_TYPES = new Set(["git"]);
|
|
17
|
+
/**
|
|
18
|
+
* Git source provider — clones (and re-pulls) a remote repo into a local
|
|
19
|
+
* cache directory. Implements the v1 {@link SourceProvider} interface (spec
|
|
20
|
+
* §2.1, §2.5): `{ name, kind, init, path, sync }`.
|
|
21
|
+
*
|
|
22
|
+
* Reading is the indexer's job — this class doesn't implement `search` or
|
|
23
|
+
* `show`. The install-time helpers `syncRegistryGitRef` / `syncMirroredRepo`
|
|
24
|
+
* live below as standalone functions used by `akm add` / `akm update`.
|
|
25
|
+
*/
|
|
26
|
+
class GitSourceProvider {
|
|
27
|
+
kind = "git";
|
|
19
28
|
name;
|
|
29
|
+
#config;
|
|
30
|
+
#path = null;
|
|
20
31
|
constructor(config) {
|
|
32
|
+
this.#config = config;
|
|
21
33
|
this.name = config.name ?? "git";
|
|
22
34
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
async init(_ctx) {
|
|
36
|
+
// Resolve the on-disk content directory once. For configured git sources
|
|
37
|
+
// this is the cached working tree; for one-shot install refs it's the
|
|
38
|
+
// path the install pipeline materialised.
|
|
39
|
+
this.#path = resolveGitContentDir(this.#config);
|
|
40
|
+
}
|
|
41
|
+
path() {
|
|
42
|
+
if (this.#path == null) {
|
|
43
|
+
// Lazy resolution: providers are sometimes constructed without an
|
|
44
|
+
// explicit init() call (e.g. by legacy callers that just want the
|
|
45
|
+
// path). Resolve on demand and cache.
|
|
46
|
+
this.#path = resolveGitContentDir(this.#config);
|
|
47
|
+
}
|
|
48
|
+
return this.#path;
|
|
49
|
+
}
|
|
50
|
+
async sync() {
|
|
51
|
+
// Two execution modes:
|
|
52
|
+
// 1. Long-lived configured source (config.url) — mirror into the
|
|
53
|
+
// registry-index cache and serve as a read-only working tree.
|
|
54
|
+
// 2. One-shot install ref (options.ref like "git:..." / "github:...") —
|
|
55
|
+
// delegate to the install-time pipeline.
|
|
56
|
+
if (typeof this.#config.options?.ref === "string" && this.#config.options.ref) {
|
|
57
|
+
await syncRegistryGitRef(String(this.#config.options.ref));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await syncMirroredRepo(this.#config);
|
|
34
61
|
}
|
|
35
62
|
}
|
|
63
|
+
/** Resolve the on-disk content directory for a configured git source. */
|
|
64
|
+
function resolveGitContentDir(config) {
|
|
65
|
+
if (config.path)
|
|
66
|
+
return config.path;
|
|
67
|
+
if (config.url) {
|
|
68
|
+
const repo = parseGitRepoUrl(config.url);
|
|
69
|
+
return getCachePaths(repo.canonicalUrl).repoDir;
|
|
70
|
+
}
|
|
71
|
+
throw new ConfigError("git source entry must have either `path` or `url`");
|
|
72
|
+
}
|
|
36
73
|
// ── Self-register ───────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
registerStashProvider("context-hub", (config) => new GitStashProvider(config));
|
|
39
|
-
registerStashProvider("github", (config) => new GitStashProvider(config));
|
|
74
|
+
registerSourceProvider("git", (config) => new GitSourceProvider(config));
|
|
40
75
|
// ── Cache management ────────────────────────────────────────────────────────
|
|
41
76
|
function getCachePaths(repoUrl) {
|
|
42
77
|
const key = createHash("sha256").update(repoUrl).digest("hex").slice(0, 16);
|
|
43
|
-
const
|
|
78
|
+
const cacheRoot = getRegistryIndexCacheDir();
|
|
79
|
+
const rootDir = path.join(cacheRoot, `git-${key}`);
|
|
80
|
+
// One-time silent migration: legacy `context-hub-${key}` directories were
|
|
81
|
+
// created for ALL git stashes (not just the andrewyng/context-hub repo). If
|
|
82
|
+
// the new path doesn't yet exist but the legacy one does, rename it in place
|
|
83
|
+
// so existing clones aren't silently invalidated. Failures are non-fatal —
|
|
84
|
+
// worst case the repo is re-cloned on the next refresh.
|
|
85
|
+
try {
|
|
86
|
+
const legacyRootDir = path.join(cacheRoot, `context-hub-${key}`);
|
|
87
|
+
if (!fs.existsSync(rootDir) && fs.existsSync(legacyRootDir)) {
|
|
88
|
+
fs.renameSync(legacyRootDir, rootDir);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* migration is best-effort */
|
|
93
|
+
}
|
|
44
94
|
return {
|
|
45
95
|
rootDir,
|
|
46
96
|
repoDir: path.join(rootDir, "repo"),
|
|
@@ -50,6 +100,7 @@ function getCachePaths(repoUrl) {
|
|
|
50
100
|
async function ensureGitMirror(repo, cachePaths, options) {
|
|
51
101
|
const requireRepoDir = options?.requireRepoDir === true;
|
|
52
102
|
const writable = options?.writable === true;
|
|
103
|
+
const force = options?.force === true;
|
|
53
104
|
// Check if cache is fresh
|
|
54
105
|
let mtime = 0;
|
|
55
106
|
try {
|
|
@@ -58,7 +109,7 @@ async function ensureGitMirror(repo, cachePaths, options) {
|
|
|
58
109
|
catch {
|
|
59
110
|
/* no cached index */
|
|
60
111
|
}
|
|
61
|
-
if (mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
112
|
+
if (!force && mtime && !isExpired(mtime, CACHE_TTL_MS) && (!requireRepoDir || hasExtractedRepo(cachePaths.repoDir))) {
|
|
62
113
|
return;
|
|
63
114
|
}
|
|
64
115
|
try {
|
|
@@ -80,6 +131,145 @@ async function ensureGitMirror(repo, cachePaths, options) {
|
|
|
80
131
|
throw err;
|
|
81
132
|
}
|
|
82
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Sync mode for a long-lived configured git stash. Mirrors the repo into the
|
|
136
|
+
* shared registry-index cache (12h TTL) and exposes the working tree as the
|
|
137
|
+
* stash content directory.
|
|
138
|
+
*/
|
|
139
|
+
async function syncMirroredRepo(config, options) {
|
|
140
|
+
if (!config.url) {
|
|
141
|
+
throw new ConfigError("git stash entry requires a URL when no install ref is supplied");
|
|
142
|
+
}
|
|
143
|
+
const repo = parseGitRepoUrl(config.url);
|
|
144
|
+
const cachePaths = getCachePaths(repo.canonicalUrl);
|
|
145
|
+
await ensureGitMirror(repo, cachePaths, {
|
|
146
|
+
requireRepoDir: true,
|
|
147
|
+
writable: options?.writable ?? config.writable === true,
|
|
148
|
+
force: options?.force,
|
|
149
|
+
});
|
|
150
|
+
const syncedAt = (options?.now ?? new Date()).toISOString();
|
|
151
|
+
const contentDir = cachePaths.repoDir;
|
|
152
|
+
return {
|
|
153
|
+
id: repo.canonicalUrl,
|
|
154
|
+
source: "git",
|
|
155
|
+
ref: repo.canonicalUrl,
|
|
156
|
+
artifactUrl: repo.canonicalUrl,
|
|
157
|
+
contentDir,
|
|
158
|
+
cacheDir: cachePaths.rootDir,
|
|
159
|
+
extractedDir: contentDir,
|
|
160
|
+
writable: options?.writable ?? config.writable === true,
|
|
161
|
+
syncedAt,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Sync mode for a one-shot install ref (`akm add github:owner/repo` or
|
|
166
|
+
* `akm add git:url`). Runs the clone → strip → include-filter pipeline that
|
|
167
|
+
* historically lived in `installRegistryRef()`.
|
|
168
|
+
*/
|
|
169
|
+
export async function syncRegistryGitRef(ref, options) {
|
|
170
|
+
const parsed = parseRegistryRef(ref);
|
|
171
|
+
if (parsed.source === "github") {
|
|
172
|
+
const githubRef = {
|
|
173
|
+
source: "git",
|
|
174
|
+
ref: parsed.ref,
|
|
175
|
+
id: parsed.id,
|
|
176
|
+
url: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
|
|
177
|
+
requestedRef: parsed.requestedRef,
|
|
178
|
+
};
|
|
179
|
+
const result = await doSyncGit(githubRef, options);
|
|
180
|
+
return { ...result, source: "github" };
|
|
181
|
+
}
|
|
182
|
+
if (parsed.source !== "git") {
|
|
183
|
+
throw new UsageError(`syncRegistryGitRef requires a git: or github: ref, got "${ref}"`);
|
|
184
|
+
}
|
|
185
|
+
return doSyncGit(parsed, options);
|
|
186
|
+
}
|
|
187
|
+
async function doSyncGit(parsed, options) {
|
|
188
|
+
const resolved = await resolveRegistryArtifact(parsed);
|
|
189
|
+
const syncedAt = (options?.now ?? new Date()).toISOString();
|
|
190
|
+
const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheDir();
|
|
191
|
+
const cacheDir = buildInstallCacheDir(cacheRootDir, parsed.source, parsed.id, resolved.resolvedRevision);
|
|
192
|
+
const cloneDir = path.join(cacheDir, "clone");
|
|
193
|
+
const extractedDir = path.join(cacheDir, "extracted");
|
|
194
|
+
// Cache hit
|
|
195
|
+
if (!options?.force && isDirectory(extractedDir)) {
|
|
196
|
+
try {
|
|
197
|
+
const provisionalKitRoot = detectStashRoot(extractedDir);
|
|
198
|
+
const installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
|
|
199
|
+
const stashRoot = detectStashRoot(installRoot);
|
|
200
|
+
if (stashRoot) {
|
|
201
|
+
return {
|
|
202
|
+
id: resolved.id,
|
|
203
|
+
source: resolved.source,
|
|
204
|
+
ref: resolved.ref,
|
|
205
|
+
artifactUrl: resolved.artifactUrl,
|
|
206
|
+
resolvedVersion: resolved.resolvedVersion,
|
|
207
|
+
resolvedRevision: resolved.resolvedRevision,
|
|
208
|
+
contentDir: stashRoot,
|
|
209
|
+
cacheDir,
|
|
210
|
+
extractedDir,
|
|
211
|
+
writable: options?.writable,
|
|
212
|
+
syncedAt,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Cache invalid, re-clone
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
221
|
+
// Validate URL and ref before passing to git to prevent command injection
|
|
222
|
+
validateGitUrl(parsed.url);
|
|
223
|
+
if (parsed.requestedRef)
|
|
224
|
+
validateGitRef(parsed.requestedRef);
|
|
225
|
+
let provisionalKitRoot;
|
|
226
|
+
let installRoot;
|
|
227
|
+
let stashRoot;
|
|
228
|
+
try {
|
|
229
|
+
const cloneArgs = ["clone", "--depth", "1"];
|
|
230
|
+
if (parsed.requestedRef) {
|
|
231
|
+
cloneArgs.push("--branch", parsed.requestedRef);
|
|
232
|
+
}
|
|
233
|
+
cloneArgs.push(parsed.url, cloneDir);
|
|
234
|
+
const cloneResult = spawnSync("git", cloneArgs, { encoding: "utf8", timeout: 120_000 });
|
|
235
|
+
if (cloneResult.status !== 0) {
|
|
236
|
+
const err = cloneResult.stderr?.trim() || cloneResult.error?.message || "unknown error";
|
|
237
|
+
throw new Error(`Failed to clone ${parsed.url}: ${err}`);
|
|
238
|
+
}
|
|
239
|
+
// Copy contents to extracted dir without .git
|
|
240
|
+
fs.mkdirSync(extractedDir, { recursive: true });
|
|
241
|
+
copyDirectoryContents(cloneDir, extractedDir);
|
|
242
|
+
// Clean up the clone dir
|
|
243
|
+
fs.rmSync(cloneDir, { recursive: true, force: true });
|
|
244
|
+
provisionalKitRoot = detectStashRoot(extractedDir);
|
|
245
|
+
installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
|
|
246
|
+
stashRoot = detectStashRoot(installRoot);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
// Clean up the cache directory so stale or partially-cloned artifacts
|
|
250
|
+
// don't cause false cache hits on the next install attempt.
|
|
251
|
+
try {
|
|
252
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
/* best-effort */
|
|
256
|
+
}
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
id: resolved.id,
|
|
261
|
+
source: resolved.source,
|
|
262
|
+
ref: resolved.ref,
|
|
263
|
+
artifactUrl: resolved.artifactUrl,
|
|
264
|
+
resolvedVersion: resolved.resolvedVersion,
|
|
265
|
+
resolvedRevision: resolved.resolvedRevision,
|
|
266
|
+
contentDir: stashRoot,
|
|
267
|
+
cacheDir,
|
|
268
|
+
extractedDir,
|
|
269
|
+
writable: options?.writable,
|
|
270
|
+
syncedAt,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
83
273
|
export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
|
|
84
274
|
// Stage the clone into a sibling temp dir so that a failed clone never
|
|
85
275
|
// destroys a previously-valid destDir (e.g. when the remote is temporarily
|
|
@@ -213,7 +403,7 @@ export function saveGitStash(name, message, writableOverride) {
|
|
|
213
403
|
let writable = false;
|
|
214
404
|
if (name) {
|
|
215
405
|
const config = loadConfig();
|
|
216
|
-
const stash = config.stashes
|
|
406
|
+
const stash = (config.sources ?? config.stashes ?? []).find((s) => s.name === name || s.url === name);
|
|
217
407
|
if (!stash)
|
|
218
408
|
throw new UsageError(`No git stash found with name "${name}"`);
|
|
219
409
|
if (!GIT_STASH_TYPES.has(stash.type)) {
|
|
@@ -275,4 +465,4 @@ export function saveGitStash(name, message, writableOverride) {
|
|
|
275
465
|
};
|
|
276
466
|
}
|
|
277
467
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
278
|
-
export { ensureGitMirror,
|
|
468
|
+
export { ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl };
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Centralized
|
|
2
|
+
* Centralized source provider registration.
|
|
3
3
|
*
|
|
4
|
-
* Import this module (side-effect import) to register all built-in
|
|
4
|
+
* Import this module (side-effect import) to register all built-in source
|
|
5
5
|
* providers with the provider registry. This replaces the individual
|
|
6
|
-
* side-effect imports that were duplicated in
|
|
6
|
+
* side-effect imports that were duplicated in source-search.ts and source-show.ts.
|
|
7
7
|
*/
|
|
8
8
|
import "./filesystem";
|
|
9
9
|
import "./git";
|
|
10
|
-
import "./
|
|
10
|
+
import "./npm";
|
|
11
11
|
import "./website";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-time types used by `syncFromRef` and the legacy install pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from the v1 {@link SourceProvider} interface (which only deals
|
|
5
|
+
* with "configured sources" — entries already resolved into a directory).
|
|
6
|
+
* These types describe the resolution+lockfile step that runs when
|
|
7
|
+
* `akm add <install-ref>` materialises an upstream artifact into a local
|
|
8
|
+
* cache directory.
|
|
9
|
+
*
|
|
10
|
+
* They live here, outside `provider.ts`, so the v1 SourceProvider
|
|
11
|
+
* interface stays minimal (`{ name, kind, init, path, sync? }`) per the
|
|
12
|
+
* architecture spec §2.1.
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Npm-source stash provider.
|
|
3
|
+
*
|
|
4
|
+
* `sync()` resolves the npm package tarball, downloads it, verifies its
|
|
5
|
+
* integrity, extracts it securely (via `extractTarGzSecure`), detects the
|
|
6
|
+
* stash root inside the package, and applies any nested `.akm-include`
|
|
7
|
+
* configuration. Cache hits short-circuit the fetch.
|
|
8
|
+
*
|
|
9
|
+
* Audit is intentionally NOT performed here — `akmAdd` calls
|
|
10
|
+
* `auditInstallCandidate` after `sync()` so the policy decision lives at
|
|
11
|
+
* the orchestrator layer where the `--trust` flag is known.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { ConfigError, UsageError } from "../../core/errors";
|
|
16
|
+
import { getRegistryCacheDir } from "../../core/paths";
|
|
17
|
+
import { parseRegistryRef, resolveRegistryArtifact } from "../../registry/resolve";
|
|
18
|
+
import { registerSourceProvider } from "../provider-factory";
|
|
19
|
+
import { applyAkmIncludeConfig, buildInstallCacheDir, computeFileHash, detectStashRoot, downloadArchive, isDirectory, } from "./provider-utils";
|
|
20
|
+
import { extractTarGzSecure, verifyArchiveIntegrity } from "./tar-utils";
|
|
21
|
+
/**
|
|
22
|
+
* NPM source provider — fetches a tarball from the npm registry and extracts
|
|
23
|
+
* it into a local cache. Implements the v1 {@link SourceProvider} interface
|
|
24
|
+
* (spec §2.1): `{ name, kind, init, path, sync }`.
|
|
25
|
+
*
|
|
26
|
+
* The install-time pipeline (`syncNpmRef`) lives below as a standalone
|
|
27
|
+
* function used by `akm add` / `akm update` — that path produces a
|
|
28
|
+
* {@link SourceLockData} record for lockfile bookkeeping. The provider's own
|
|
29
|
+
* `sync()` is a void refresh (delegates to the install pipeline but discards
|
|
30
|
+
* the lock data, which is owned by `lockfile.ts`).
|
|
31
|
+
*/
|
|
32
|
+
class NpmSourceProvider {
|
|
33
|
+
kind = "npm";
|
|
34
|
+
name;
|
|
35
|
+
#config;
|
|
36
|
+
constructor(config) {
|
|
37
|
+
this.#config = config;
|
|
38
|
+
this.name = config.name ?? config.url ?? "npm";
|
|
39
|
+
}
|
|
40
|
+
async init(_ctx) {
|
|
41
|
+
// Resolution happens lazily in path(): until `sync()` runs there's no
|
|
42
|
+
// reliable on-disk path. Init is the registration handshake.
|
|
43
|
+
}
|
|
44
|
+
path() {
|
|
45
|
+
if (this.#config.path)
|
|
46
|
+
return this.#config.path;
|
|
47
|
+
throw new ConfigError(`npm source "${this.name}" has no resolved content path — run \`akm update\` to sync it before indexing.`);
|
|
48
|
+
}
|
|
49
|
+
async sync() {
|
|
50
|
+
const ref = npmRefFromConfig(this.#config);
|
|
51
|
+
await syncNpmRef(ref);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
registerSourceProvider("npm", (config) => new NpmSourceProvider(config));
|
|
55
|
+
function npmRefFromConfig(config) {
|
|
56
|
+
// Prefer an explicit ref-bearing field (set by akmAdd when persisting), else fall back
|
|
57
|
+
// to options or url so the provider stays usable from a hand-rolled config.
|
|
58
|
+
const candidate = config.options?.ref ?? config.url ?? config.options?.package ?? config.name;
|
|
59
|
+
if (typeof candidate !== "string" || !candidate) {
|
|
60
|
+
throw new UsageError('npm stash entry must include an `options.ref` (e.g. "npm:my-pkg@1.2.3")');
|
|
61
|
+
}
|
|
62
|
+
return candidate.startsWith("npm:") ? candidate : `npm:${candidate}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Fetch and extract an npm tarball, returning a populated `SourceLockData`.
|
|
66
|
+
*
|
|
67
|
+
* Mirrors the historical `installRegistryRef()` path for npm sources:
|
|
68
|
+
* - resolve artifact URL + integrity from the npm registry
|
|
69
|
+
* - reuse cached extraction when present
|
|
70
|
+
* - download, verify, extract securely, then detect the stash root
|
|
71
|
+
* - honour `.akm-include` filters
|
|
72
|
+
*/
|
|
73
|
+
export async function syncNpmRef(ref, options) {
|
|
74
|
+
const parsed = parseRegistryRef(ref);
|
|
75
|
+
if (parsed.source !== "npm") {
|
|
76
|
+
throw new UsageError(`syncNpmRef requires an npm: ref, got "${ref}"`);
|
|
77
|
+
}
|
|
78
|
+
return doSyncNpm(parsed, options);
|
|
79
|
+
}
|
|
80
|
+
async function doSyncNpm(parsed, options) {
|
|
81
|
+
const resolved = await resolveRegistryArtifact(parsed);
|
|
82
|
+
const syncedAt = (options?.now ?? new Date()).toISOString();
|
|
83
|
+
const cacheRootDir = options?.cacheRootDir ?? getRegistryCacheDir();
|
|
84
|
+
const cacheDir = buildInstallCacheDir(cacheRootDir, resolved.source, resolved.id, resolved.resolvedVersion ?? resolved.resolvedRevision);
|
|
85
|
+
const archivePath = path.join(cacheDir, "artifact.tar.gz");
|
|
86
|
+
const extractedDir = path.join(cacheDir, "extracted");
|
|
87
|
+
// Cache hit: extracted dir already valid → reuse it
|
|
88
|
+
if (!options?.force && isDirectory(extractedDir)) {
|
|
89
|
+
try {
|
|
90
|
+
const cachedStashRoot = detectStashRoot(extractedDir);
|
|
91
|
+
if (cachedStashRoot) {
|
|
92
|
+
const integrity = fs.existsSync(archivePath) ? await computeFileHash(archivePath) : undefined;
|
|
93
|
+
return {
|
|
94
|
+
id: resolved.id,
|
|
95
|
+
source: resolved.source,
|
|
96
|
+
ref: resolved.ref,
|
|
97
|
+
artifactUrl: resolved.artifactUrl,
|
|
98
|
+
resolvedVersion: resolved.resolvedVersion,
|
|
99
|
+
resolvedRevision: resolved.resolvedRevision,
|
|
100
|
+
contentDir: cachedStashRoot,
|
|
101
|
+
cacheDir,
|
|
102
|
+
extractedDir,
|
|
103
|
+
integrity,
|
|
104
|
+
writable: options?.writable,
|
|
105
|
+
syncedAt,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Cache invalid, re-download
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
114
|
+
let integrity;
|
|
115
|
+
let provisionalKitRoot;
|
|
116
|
+
let installRoot;
|
|
117
|
+
let stashRoot;
|
|
118
|
+
try {
|
|
119
|
+
await downloadArchive(resolved.artifactUrl, archivePath);
|
|
120
|
+
verifyArchiveIntegrity(archivePath, resolved.resolvedRevision, resolved.source);
|
|
121
|
+
integrity = await computeFileHash(archivePath);
|
|
122
|
+
extractTarGzSecure(archivePath, extractedDir);
|
|
123
|
+
const detectedProvisionalKitRoot = detectStashRoot(extractedDir);
|
|
124
|
+
if (!detectedProvisionalKitRoot) {
|
|
125
|
+
throw new UsageError(`Unable to detect a stash root in extracted npm package: ${resolved.ref}`);
|
|
126
|
+
}
|
|
127
|
+
provisionalKitRoot = detectedProvisionalKitRoot;
|
|
128
|
+
installRoot = applyAkmIncludeConfig(provisionalKitRoot, cacheDir, extractedDir) ?? provisionalKitRoot;
|
|
129
|
+
const detectedStashRoot = detectStashRoot(installRoot);
|
|
130
|
+
if (!detectedStashRoot) {
|
|
131
|
+
throw new UsageError(`Unable to detect a stash root after applying .akm-include configuration for npm package: ${resolved.ref}`);
|
|
132
|
+
}
|
|
133
|
+
stashRoot = detectedStashRoot;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
// Clean up so stale or partial extractions don't cause false cache hits.
|
|
137
|
+
try {
|
|
138
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* best-effort */
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
id: resolved.id,
|
|
147
|
+
source: resolved.source,
|
|
148
|
+
ref: resolved.ref,
|
|
149
|
+
artifactUrl: resolved.artifactUrl,
|
|
150
|
+
resolvedVersion: resolved.resolvedVersion,
|
|
151
|
+
resolvedRevision: resolved.resolvedRevision,
|
|
152
|
+
contentDir: stashRoot,
|
|
153
|
+
cacheDir,
|
|
154
|
+
extractedDir,
|
|
155
|
+
integrity,
|
|
156
|
+
writable: options?.writable,
|
|
157
|
+
syncedAt,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export { NpmSourceProvider };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { TYPE_DIRS } from "../../core/asset-spec";
|
|
5
|
+
import { fetchWithRetry } from "../../core/common";
|
|
6
|
+
import { copyIncludedPaths, findNearestIncludeConfig } from "../include";
|
|
7
|
+
const REGISTRY_STASH_DIR_NAMES = new Set(Object.values(TYPE_DIRS));
|
|
8
|
+
/** Strip terminal control characters from untrusted strings. */
|
|
9
|
+
export function sanitizeString(value, maxLength = 255) {
|
|
10
|
+
if (typeof value !== "string")
|
|
11
|
+
return "";
|
|
12
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — strip control chars from untrusted remote data
|
|
13
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, maxLength);
|
|
14
|
+
}
|
|
15
|
+
/** Check whether a cached timestamp has exceeded its TTL. */
|
|
16
|
+
export function isExpired(mtimeMs, ttlMs) {
|
|
17
|
+
return Date.now() - mtimeMs > ttlMs;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Find the directory inside `extractedDir` that should be treated as the
|
|
21
|
+
* stash root. Looks for a `.stash` marker, then well-known type dirs, then
|
|
22
|
+
* BFS for the shallowest such candidate.
|
|
23
|
+
*/
|
|
24
|
+
export function detectStashRoot(extractedDir) {
|
|
25
|
+
const root = path.resolve(extractedDir);
|
|
26
|
+
const rootDotStash = path.join(root, ".stash");
|
|
27
|
+
if (isDirectory(rootDotStash)) {
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
if (hasStashDirs(root)) {
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
const shallowest = findShallowestStashRoot(root);
|
|
34
|
+
if (shallowest)
|
|
35
|
+
return shallowest;
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build a per-source cache directory under `cacheRootDir`.
|
|
40
|
+
*
|
|
41
|
+
* Versioned sources get `${source}-${id}/${version}` for cache reuse;
|
|
42
|
+
* `local` sources get a unique timestamped slug so each install is isolated.
|
|
43
|
+
*/
|
|
44
|
+
export function buildInstallCacheDir(cacheRootDir, source, id, version) {
|
|
45
|
+
const slug = `${source}-${id.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
46
|
+
const versionSlug = source === "local"
|
|
47
|
+
? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
48
|
+
: (version?.replace(/[^a-zA-Z0-9_.-]+/g, "-") ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`);
|
|
49
|
+
return path.join(cacheRootDir, slug || source, versionSlug);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Apply an `.akm-include` config (if any) by copying the selected paths
|
|
53
|
+
* into a sibling `selected/` directory and returning that path. Returns
|
|
54
|
+
* undefined when no include config is found.
|
|
55
|
+
*/
|
|
56
|
+
export function applyAkmIncludeConfig(sourceRoot, cacheDir, searchRoot = sourceRoot) {
|
|
57
|
+
const includeConfig = findNearestIncludeConfig(sourceRoot, searchRoot);
|
|
58
|
+
if (!includeConfig)
|
|
59
|
+
return undefined;
|
|
60
|
+
const selectedDir = path.join(cacheDir, "selected");
|
|
61
|
+
fs.rmSync(selectedDir, { recursive: true, force: true });
|
|
62
|
+
fs.mkdirSync(selectedDir, { recursive: true });
|
|
63
|
+
copyIncludedPaths(includeConfig.include, includeConfig.baseDir, selectedDir);
|
|
64
|
+
return selectedDir;
|
|
65
|
+
}
|
|
66
|
+
/** Stream a remote archive to disk using Bun.write when available. */
|
|
67
|
+
export async function downloadArchive(url, destination) {
|
|
68
|
+
const response = await fetchWithRetry(url, undefined, { timeout: 120_000 });
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new Error(`Failed to download archive (${response.status}) from ${url}`);
|
|
71
|
+
}
|
|
72
|
+
// Stream response to disk instead of buffering the entire archive in memory.
|
|
73
|
+
// Uses Bun.write which handles Response streaming natively.
|
|
74
|
+
const BunRuntime = globalThis
|
|
75
|
+
.Bun;
|
|
76
|
+
if (BunRuntime?.write) {
|
|
77
|
+
await BunRuntime.write(destination, response);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// Fallback for non-Bun environments (e.g., tests)
|
|
81
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
82
|
+
fs.writeFileSync(destination, Buffer.from(arrayBuffer));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** SHA-256 of a file, returned as `sha256:<hex>`. */
|
|
86
|
+
export async function computeFileHash(filePath) {
|
|
87
|
+
const data = fs.readFileSync(filePath);
|
|
88
|
+
const hash = createHash("sha256").update(data).digest("hex");
|
|
89
|
+
return `sha256:${hash}`;
|
|
90
|
+
}
|
|
91
|
+
/** Recursively copy directory contents, excluding `.git`. */
|
|
92
|
+
export function copyDirectoryContents(sourceDir, destinationDir) {
|
|
93
|
+
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
94
|
+
if (entry.name === ".git")
|
|
95
|
+
continue;
|
|
96
|
+
const src = path.join(sourceDir, entry.name);
|
|
97
|
+
const dest = path.join(destinationDir, entry.name);
|
|
98
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
99
|
+
if (entry.isDirectory()) {
|
|
100
|
+
fs.cpSync(src, dest, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
fs.copyFileSync(src, dest);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export function isDirectory(target) {
|
|
108
|
+
try {
|
|
109
|
+
return fs.statSync(target).isDirectory();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function hasStashDirs(dirPath) {
|
|
116
|
+
if (!isDirectory(dirPath))
|
|
117
|
+
return false;
|
|
118
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
119
|
+
return entries.some((entry) => entry.isDirectory() && REGISTRY_STASH_DIR_NAMES.has(entry.name));
|
|
120
|
+
}
|
|
121
|
+
function countStashDirs(dirPath) {
|
|
122
|
+
if (!isDirectory(dirPath))
|
|
123
|
+
return 0;
|
|
124
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
125
|
+
return entries.filter((entry) => entry.isDirectory() && REGISTRY_STASH_DIR_NAMES.has(entry.name)).length;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* BFS to find the shallowest directory that looks like a stash root.
|
|
129
|
+
* Checks for both `.stash` directories and well-known type directories
|
|
130
|
+
* (scripts/, skills/, etc.), so nested layouts like `project/my-stash/scripts/`
|
|
131
|
+
* are discovered even without a `.stash` marker.
|
|
132
|
+
*
|
|
133
|
+
* Skips `root` itself since the caller already checked it via `hasStashDirs`.
|
|
134
|
+
*/
|
|
135
|
+
const BFS_MAX_DEPTH = 5;
|
|
136
|
+
function findShallowestStashRoot(root) {
|
|
137
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
138
|
+
while (queue.length > 0) {
|
|
139
|
+
const item = queue.shift();
|
|
140
|
+
if (!item) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const { dir: current, depth } = item;
|
|
144
|
+
if (current !== root) {
|
|
145
|
+
// .stash directory is a strong stash marker
|
|
146
|
+
if (isDirectory(path.join(current, ".stash"))) {
|
|
147
|
+
return current;
|
|
148
|
+
}
|
|
149
|
+
// Require 2+ type dirs for BFS candidates to avoid false positives.
|
|
150
|
+
// A single "scripts/" is too common (skill dirs, npm packages, etc.).
|
|
151
|
+
if (countStashDirs(current) >= 2) {
|
|
152
|
+
return current;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (depth >= BFS_MAX_DEPTH)
|
|
156
|
+
continue;
|
|
157
|
+
let children;
|
|
158
|
+
try {
|
|
159
|
+
children = fs.readdirSync(current, { withFileTypes: true });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
for (const child of children) {
|
|
165
|
+
if (!child.isDirectory())
|
|
166
|
+
continue;
|
|
167
|
+
if (child.name === ".git" || child.name === "node_modules")
|
|
168
|
+
continue;
|
|
169
|
+
queue.push({ dir: path.join(current, child.name), depth: depth + 1 });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|