akm-cli 0.4.0 → 0.5.0-rc1
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/dist/asset-registry.js +7 -0
- package/dist/asset-spec.js +35 -0
- package/dist/cli.js +1383 -25
- package/dist/completions.js +2 -2
- package/dist/config-cli.js +41 -0
- package/dist/config.js +62 -0
- package/dist/embedder.js +26 -4
- package/dist/file-context.js +2 -1
- package/dist/github.js +20 -1
- package/dist/indexer.js +55 -5
- package/dist/init.js +11 -0
- package/dist/install-audit.js +53 -8
- package/dist/installed-kits.js +2 -0
- package/dist/llm.js +64 -23
- package/dist/matchers.js +56 -4
- package/dist/metadata.js +68 -4
- package/dist/paths.js +3 -0
- package/dist/registry-install.js +36 -7
- package/dist/registry-resolve.js +25 -0
- package/dist/renderers.js +182 -2
- package/dist/search-fields.js +4 -0
- package/dist/search-source.js +12 -8
- package/dist/setup.js +158 -33
- package/dist/stash-add.js +84 -11
- package/dist/stash-providers/git.js +182 -44
- package/dist/stash-show.js +56 -1
- package/dist/stash-source-manage.js +14 -4
- package/dist/templates/wiki-templates.js +100 -0
- package/dist/vault.js +290 -0
- package/dist/wiki.js +886 -0
- package/dist/workflow-authoring.js +131 -0
- package/dist/workflow-cli.js +44 -0
- package/dist/workflow-db.js +55 -0
- package/dist/workflow-markdown.js +251 -0
- package/dist/workflow-runs.js +364 -0
- package/package.json +2 -1
- package/LICENSE +0 -374
package/dist/stash-add.js
CHANGED
|
@@ -8,32 +8,50 @@ import { upsertLockEntry } from "./lockfile";
|
|
|
8
8
|
import { detectStashRoot, installRegistryRef, upsertInstalledRegistryEntry } from "./registry-install";
|
|
9
9
|
import { parseRegistryRef } from "./registry-resolve";
|
|
10
10
|
import { ensureWebsiteMirror, validateWebsiteInputUrl } from "./stash-providers/website";
|
|
11
|
+
import { warn } from "./warn";
|
|
12
|
+
import { validateWikiName } from "./wiki";
|
|
13
|
+
const VALID_OVERRIDE_TYPES = new Set(["wiki"]);
|
|
11
14
|
export async function akmAdd(input) {
|
|
12
15
|
const ref = input.ref.trim();
|
|
13
16
|
if (!ref)
|
|
14
17
|
throw new UsageError("Install ref or local directory is required. " +
|
|
15
18
|
"Examples: `akm add @scope/kit`, `akm add github:owner/repo`, `akm add ./local/path`");
|
|
19
|
+
// Validate and resolve wiki name when --type wiki is used
|
|
20
|
+
let wikiName;
|
|
21
|
+
if (input.overrideType) {
|
|
22
|
+
if (!VALID_OVERRIDE_TYPES.has(input.overrideType)) {
|
|
23
|
+
throw new UsageError(`Invalid --type value: "${input.overrideType}". Supported types: ${[...VALID_OVERRIDE_TYPES].join(", ")}`);
|
|
24
|
+
}
|
|
25
|
+
if (input.overrideType === "wiki") {
|
|
26
|
+
const derived = input.name ?? deriveWikiNameFromRef(ref);
|
|
27
|
+
validateWikiName(derived);
|
|
28
|
+
wikiName = derived;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
16
31
|
const stashDir = resolveStashDir();
|
|
17
32
|
if (shouldAddAsWebsiteUrl(ref)) {
|
|
18
|
-
return addWebsiteStashSource(ref, stashDir, input.name, input.options);
|
|
33
|
+
return addWebsiteStashSource(ref, stashDir, input.name ?? wikiName, input.options, wikiName);
|
|
19
34
|
}
|
|
20
35
|
// Detect local directory refs and route them to stashes[] instead of installed[]
|
|
21
36
|
try {
|
|
22
37
|
const parsed = parseRegistryRef(ref);
|
|
23
38
|
if (parsed.source === "local") {
|
|
24
|
-
|
|
39
|
+
if (input.trustThisInstall) {
|
|
40
|
+
warn("--trust has no effect on local directory sources; the install audit is not run for local paths.");
|
|
41
|
+
}
|
|
42
|
+
return addLocalStashSource(ref, parsed.sourcePath, stashDir, wikiName);
|
|
25
43
|
}
|
|
26
44
|
}
|
|
27
45
|
catch {
|
|
28
46
|
// Not a local ref — fall through to registry install
|
|
29
47
|
}
|
|
30
|
-
return addRegistryKit(ref, stashDir);
|
|
48
|
+
return addRegistryKit(ref, stashDir, input.trustThisInstall, input.writable, wikiName);
|
|
31
49
|
}
|
|
32
50
|
/**
|
|
33
51
|
* Add a local directory as a filesystem stash source.
|
|
34
52
|
* Creates a stashes[] entry instead of an installed[] entry.
|
|
35
53
|
*/
|
|
36
|
-
async function addLocalStashSource(ref, sourcePath, stashDir) {
|
|
54
|
+
async function addLocalStashSource(ref, sourcePath, stashDir, wikiName) {
|
|
37
55
|
const stashRoot = detectStashRoot(sourcePath);
|
|
38
56
|
const resolvedPath = path.resolve(stashRoot);
|
|
39
57
|
const config = loadUserConfig();
|
|
@@ -44,11 +62,16 @@ async function addLocalStashSource(ref, sourcePath, stashDir) {
|
|
|
44
62
|
const entry = {
|
|
45
63
|
type: "filesystem",
|
|
46
64
|
path: resolvedPath,
|
|
47
|
-
name: toReadableId(resolvedPath),
|
|
65
|
+
name: wikiName ?? toReadableId(resolvedPath),
|
|
66
|
+
...(wikiName ? { wikiName } : {}),
|
|
48
67
|
};
|
|
49
68
|
stashes.push(entry);
|
|
50
69
|
saveConfig({ ...config, stashes });
|
|
51
70
|
}
|
|
71
|
+
else if (wikiName && existing.wikiName !== wikiName) {
|
|
72
|
+
existing.wikiName = wikiName;
|
|
73
|
+
saveConfig({ ...config, stashes });
|
|
74
|
+
}
|
|
52
75
|
const index = await akmIndex({ stashDir });
|
|
53
76
|
const updatedConfig = loadConfig();
|
|
54
77
|
return {
|
|
@@ -73,7 +96,7 @@ async function addLocalStashSource(ref, sourcePath, stashDir) {
|
|
|
73
96
|
},
|
|
74
97
|
};
|
|
75
98
|
}
|
|
76
|
-
async function addWebsiteStashSource(ref, stashDir, name, options) {
|
|
99
|
+
async function addWebsiteStashSource(ref, stashDir, name, options, wikiName) {
|
|
77
100
|
const normalizedUrl = validateWebsiteInputUrl(ref);
|
|
78
101
|
const config = loadUserConfig();
|
|
79
102
|
const stashes = [...(config.stashes ?? [])];
|
|
@@ -84,13 +107,23 @@ async function addWebsiteStashSource(ref, stashDir, name, options) {
|
|
|
84
107
|
url: normalizedUrl,
|
|
85
108
|
name: name ?? toWebsiteName(normalizedUrl),
|
|
86
109
|
...(options && Object.keys(options).length > 0 ? { options } : {}),
|
|
110
|
+
...(wikiName ? { wikiName } : {}),
|
|
87
111
|
};
|
|
88
112
|
stashes.push(entry);
|
|
89
113
|
saveConfig({ ...config, stashes });
|
|
90
114
|
}
|
|
91
|
-
else
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
else {
|
|
116
|
+
let changed = false;
|
|
117
|
+
if (options && Object.keys(options).length > 0) {
|
|
118
|
+
entry.options = { ...entry.options, ...options };
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
if (wikiName && entry.wikiName !== wikiName) {
|
|
122
|
+
entry.wikiName = wikiName;
|
|
123
|
+
changed = true;
|
|
124
|
+
}
|
|
125
|
+
if (changed)
|
|
126
|
+
saveConfig({ ...config, stashes });
|
|
94
127
|
}
|
|
95
128
|
const cachePaths = await ensureWebsiteMirror(entry, { requireStashDir: true });
|
|
96
129
|
const index = await akmIndex({ stashDir });
|
|
@@ -120,8 +153,8 @@ async function addWebsiteStashSource(ref, stashDir, name, options) {
|
|
|
120
153
|
/**
|
|
121
154
|
* Install a kit from a registry (npm, github, git).
|
|
122
155
|
*/
|
|
123
|
-
async function addRegistryKit(ref, stashDir) {
|
|
124
|
-
const installed = await installRegistryRef(ref);
|
|
156
|
+
async function addRegistryKit(ref, stashDir, trustThisInstall, writable, wikiName) {
|
|
157
|
+
const installed = await installRegistryRef(ref, { trustThisInstall, writable });
|
|
125
158
|
const replaced = (loadConfig().installed ?? []).find((entry) => entry.id === installed.id);
|
|
126
159
|
const config = upsertInstalledRegistryEntry({
|
|
127
160
|
id: installed.id,
|
|
@@ -133,6 +166,8 @@ async function addRegistryKit(ref, stashDir) {
|
|
|
133
166
|
stashRoot: installed.stashRoot,
|
|
134
167
|
cacheDir: installed.cacheDir,
|
|
135
168
|
installedAt: installed.installedAt,
|
|
169
|
+
writable: installed.writable,
|
|
170
|
+
...(wikiName ? { wikiName } : {}),
|
|
136
171
|
});
|
|
137
172
|
await upsertLockEntry({
|
|
138
173
|
id: installed.id,
|
|
@@ -211,3 +246,41 @@ function toWebsiteName(siteUrl) {
|
|
|
211
246
|
return siteUrl;
|
|
212
247
|
}
|
|
213
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Derive a wiki name from a ref string when --name is not provided.
|
|
251
|
+
* Lowercases and slugifies the most meaningful identifier segment.
|
|
252
|
+
*/
|
|
253
|
+
export function deriveWikiNameFromRef(ref) {
|
|
254
|
+
let candidate = ref;
|
|
255
|
+
// github:owner/repo or github:owner/repo@ref
|
|
256
|
+
if (/^github:/i.test(ref)) {
|
|
257
|
+
const repoPath = ref.replace(/^github:/i, "").split("@")[0];
|
|
258
|
+
candidate = repoPath.split("/").pop() ?? repoPath;
|
|
259
|
+
}
|
|
260
|
+
// npm:pkg or @scope/pkg
|
|
261
|
+
else if (/^npm:/i.test(ref) || ref.startsWith("@")) {
|
|
262
|
+
candidate = ref
|
|
263
|
+
.replace(/^npm:/i, "")
|
|
264
|
+
.replace(/^@[^/]+\//, "")
|
|
265
|
+
.split("@")[0];
|
|
266
|
+
}
|
|
267
|
+
// git URLs or HTTPS git URLs
|
|
268
|
+
else if (/^(git:|https?:\/\/)/.test(ref)) {
|
|
269
|
+
try {
|
|
270
|
+
candidate = new URL(ref).pathname.split("/").pop() ?? candidate;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
candidate = ref.split("/").pop() ?? ref;
|
|
274
|
+
}
|
|
275
|
+
candidate = candidate.replace(/\.git$/, "");
|
|
276
|
+
}
|
|
277
|
+
// Local paths
|
|
278
|
+
else {
|
|
279
|
+
candidate = path.basename(ref.replace(/\/+$/, ""));
|
|
280
|
+
}
|
|
281
|
+
return candidate
|
|
282
|
+
.toLowerCase()
|
|
283
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
284
|
+
.replace(/^-+|-+$/g, "")
|
|
285
|
+
.slice(0, 64);
|
|
286
|
+
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { resolveStashDir } from "../common";
|
|
6
|
+
import { loadConfig } from "../config";
|
|
7
|
+
import { ConfigError, UsageError } from "../errors";
|
|
6
8
|
import { getRegistryIndexCacheDir } from "../paths";
|
|
7
|
-
import {
|
|
9
|
+
import { validateGitUrl } from "../registry-resolve";
|
|
8
10
|
import { registerStashProvider } from "../stash-provider-factory";
|
|
9
11
|
import { isExpired, sanitizeString } from "./provider-utils";
|
|
10
12
|
/** Cache TTL before refreshing the mirrored repo (12 hours). */
|
|
11
13
|
const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
12
14
|
/** Maximum stale age allowed when refresh fails (7 days). */
|
|
13
15
|
const CACHE_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const GIT_STASH_TYPES = new Set(["git", "context-hub", "github"]);
|
|
14
17
|
class GitStashProvider {
|
|
15
18
|
type = "git";
|
|
16
19
|
name;
|
|
@@ -40,13 +43,13 @@ function getCachePaths(repoUrl) {
|
|
|
40
43
|
const rootDir = path.join(getRegistryIndexCacheDir(), `context-hub-${key}`);
|
|
41
44
|
return {
|
|
42
45
|
rootDir,
|
|
43
|
-
archivePath: path.join(rootDir, "repo.tar.gz"),
|
|
44
46
|
repoDir: path.join(rootDir, "repo"),
|
|
45
47
|
indexPath: path.join(rootDir, "index.json"),
|
|
46
48
|
};
|
|
47
49
|
}
|
|
48
50
|
async function ensureGitMirror(repo, cachePaths, options) {
|
|
49
51
|
const requireRepoDir = options?.requireRepoDir === true;
|
|
52
|
+
const writable = options?.writable === true;
|
|
50
53
|
// Check if cache is fresh
|
|
51
54
|
let mtime = 0;
|
|
52
55
|
try {
|
|
@@ -60,8 +63,13 @@ async function ensureGitMirror(repo, cachePaths, options) {
|
|
|
60
63
|
}
|
|
61
64
|
try {
|
|
62
65
|
fs.mkdirSync(cachePaths.rootDir, { recursive: true });
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
if (writable && fs.existsSync(path.join(cachePaths.repoDir, ".git"))) {
|
|
67
|
+
// Writable repo already cloned — pull instead of re-clone to preserve local changes
|
|
68
|
+
pullRepo(cachePaths.repoDir);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
cloneRepo(repo.cloneUrl, repo.ref, cachePaths.repoDir, writable);
|
|
72
|
+
}
|
|
65
73
|
// Touch index file to track freshness
|
|
66
74
|
fs.writeFileSync(cachePaths.indexPath, "[]", { encoding: "utf8", mode: 0o600 });
|
|
67
75
|
}
|
|
@@ -72,6 +80,50 @@ async function ensureGitMirror(repo, cachePaths, options) {
|
|
|
72
80
|
throw err;
|
|
73
81
|
}
|
|
74
82
|
}
|
|
83
|
+
export function cloneRepo(cloneUrl, ref, destDir, writable = false) {
|
|
84
|
+
// Stage the clone into a sibling temp dir so that a failed clone never
|
|
85
|
+
// destroys a previously-valid destDir (e.g. when the remote is temporarily
|
|
86
|
+
// unreachable and we have a valid cached copy).
|
|
87
|
+
const tmpDir = `${destDir}.tmp-${randomBytes(4).toString("hex")}`;
|
|
88
|
+
const args = ["clone", "--depth", "1"];
|
|
89
|
+
if (ref)
|
|
90
|
+
args.push("--branch", ref);
|
|
91
|
+
args.push(cloneUrl, tmpDir);
|
|
92
|
+
const result = spawnSync("git", args, { encoding: "utf8", timeout: 120_000 });
|
|
93
|
+
if (result.status !== 0) {
|
|
94
|
+
// Clean up the (possibly partial) temp dir but leave destDir untouched.
|
|
95
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
96
|
+
const err = result.stderr?.trim() || result.error?.message || "unknown error";
|
|
97
|
+
throw new Error(`Failed to clone ${cloneUrl}: ${err}`);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (!writable) {
|
|
101
|
+
// Remove .git directory — we only need the working tree for read-only stashes
|
|
102
|
+
const gitDir = path.join(tmpDir, ".git");
|
|
103
|
+
if (fs.existsSync(gitDir))
|
|
104
|
+
fs.rmSync(gitDir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
// Swap: remove the old destDir (if any) then atomically rename tmpDir into place.
|
|
107
|
+
if (fs.existsSync(destDir))
|
|
108
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
109
|
+
fs.renameSync(tmpDir, destDir);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
// Post-clone steps failed — clean up the temp dir to avoid orphaned dirs.
|
|
113
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function pullRepo(repoDir) {
|
|
118
|
+
const result = spawnSync("git", ["-C", repoDir, "pull", "--ff-only"], {
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
timeout: 120_000,
|
|
121
|
+
});
|
|
122
|
+
if (result.status !== 0) {
|
|
123
|
+
const err = result.stderr?.trim() || result.error?.message || "unknown error";
|
|
124
|
+
throw new Error(`Failed to pull ${repoDir}: ${err}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
75
127
|
function hasExtractedRepo(repoDir) {
|
|
76
128
|
try {
|
|
77
129
|
return fs.statSync(repoDir).isDirectory() && fs.statSync(path.join(repoDir, "content")).isDirectory();
|
|
@@ -80,25 +132,22 @@ function hasExtractedRepo(repoDir) {
|
|
|
80
132
|
return false;
|
|
81
133
|
}
|
|
82
134
|
}
|
|
83
|
-
async function downloadArchive(url, destination) {
|
|
84
|
-
const response = await fetchWithRetry(url, undefined, { timeout: 120_000, retries: 1 });
|
|
85
|
-
if (!response.ok) {
|
|
86
|
-
throw new Error(`Failed to download archive (${response.status}) from ${url}`);
|
|
87
|
-
}
|
|
88
|
-
const BunRuntime = globalThis.Bun;
|
|
89
|
-
if (BunRuntime?.write) {
|
|
90
|
-
await BunRuntime.write(destination, response);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
const arrayBuffer = await response.arrayBuffer();
|
|
94
|
-
fs.writeFileSync(destination, Buffer.from(arrayBuffer));
|
|
95
|
-
}
|
|
96
|
-
function buildTarballUrl(repo) {
|
|
97
|
-
return `https://github.com/${repo.owner}/${repo.repo}/archive/refs/heads/${repo.ref}.tar.gz`;
|
|
98
|
-
}
|
|
99
135
|
function parseGitRepoUrl(rawUrl) {
|
|
100
136
|
if (!rawUrl) {
|
|
101
|
-
throw new ConfigError("Git provider requires a
|
|
137
|
+
throw new ConfigError("Git provider requires a repository URL");
|
|
138
|
+
}
|
|
139
|
+
// SSH shorthand: git@host:path — valid as-is, delegated to system git credentials
|
|
140
|
+
if (/^git@[^:]+:.+$/.test(rawUrl)) {
|
|
141
|
+
return { cloneUrl: rawUrl, ref: null, canonicalUrl: rawUrl };
|
|
142
|
+
}
|
|
143
|
+
// Validate URL scheme is safe before parsing
|
|
144
|
+
try {
|
|
145
|
+
validateGitUrl(rawUrl);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (err instanceof UsageError)
|
|
149
|
+
throw new ConfigError(err.message);
|
|
150
|
+
throw err;
|
|
102
151
|
}
|
|
103
152
|
let parsed;
|
|
104
153
|
try {
|
|
@@ -107,33 +156,122 @@ function parseGitRepoUrl(rawUrl) {
|
|
|
107
156
|
catch {
|
|
108
157
|
throw new ConfigError(`Git provider URL is not valid: "${rawUrl}"`);
|
|
109
158
|
}
|
|
110
|
-
|
|
111
|
-
|
|
159
|
+
// GitHub web URLs: extract a clean clone URL and optional branch from /tree/<ref>
|
|
160
|
+
if (parsed.hostname === "github.com" && parsed.protocol === "https:") {
|
|
161
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
162
|
+
if (segments.length < 2) {
|
|
163
|
+
throw new ConfigError(`Git provider URL must point to a repository, got "${rawUrl}"`);
|
|
164
|
+
}
|
|
165
|
+
const owner = sanitizeString(segments[0]);
|
|
166
|
+
const repo = sanitizeString(segments[1].replace(/\.git$/i, ""));
|
|
167
|
+
if (!owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) {
|
|
168
|
+
throw new ConfigError(`Unsupported repository URL: "${rawUrl}"`);
|
|
169
|
+
}
|
|
170
|
+
let ref = null;
|
|
171
|
+
if (segments[2] === "tree" && segments.length >= 4) {
|
|
172
|
+
const rawRef = sanitizeString(segments.slice(3).join("/"), 255);
|
|
173
|
+
if (rawRef && !rawRef.includes("..") && /^[A-Za-z0-9._/-]+$/.test(rawRef)) {
|
|
174
|
+
ref = rawRef;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const cloneUrl = `https://github.com/${owner}/${repo}`;
|
|
178
|
+
const canonicalUrl = ref ? `${cloneUrl}/tree/${ref}` : cloneUrl;
|
|
179
|
+
return { cloneUrl, ref, canonicalUrl };
|
|
180
|
+
}
|
|
181
|
+
// Any other valid git URL: use as-is for cloning, but strip embedded credentials
|
|
182
|
+
// from canonicalUrl so secrets don't leak into cache keys or warning messages.
|
|
183
|
+
let canonicalUrl = rawUrl;
|
|
184
|
+
try {
|
|
185
|
+
const u = new URL(rawUrl);
|
|
186
|
+
u.username = "";
|
|
187
|
+
u.password = "";
|
|
188
|
+
u.search = "";
|
|
189
|
+
u.hash = "";
|
|
190
|
+
canonicalUrl = u.toString();
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// URL failed to parse — fall back to raw (validateGitUrl already accepted it)
|
|
194
|
+
}
|
|
195
|
+
return { cloneUrl: rawUrl, ref: null, canonicalUrl };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Commit (and optionally push) local changes in a git-backed stash.
|
|
199
|
+
*
|
|
200
|
+
* Behaviour:
|
|
201
|
+
* - Not a git repo → skipped (no-op)
|
|
202
|
+
* - Git repo, no remote → commit only
|
|
203
|
+
* - Git repo, has remote, but stash is not writable → commit only
|
|
204
|
+
* - Git repo, has remote, stash is writable → commit + push
|
|
205
|
+
*
|
|
206
|
+
* When `name` is omitted the primary stash directory is used.
|
|
207
|
+
* When `message` is omitted a timestamp is used.
|
|
208
|
+
*/
|
|
209
|
+
export function saveGitStash(name, message, writableOverride) {
|
|
210
|
+
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
211
|
+
const commitMessage = message?.trim() || `akm save ${timestamp}`;
|
|
212
|
+
let repoDir;
|
|
213
|
+
let writable = false;
|
|
214
|
+
if (name) {
|
|
215
|
+
const config = loadConfig();
|
|
216
|
+
const stash = config.stashes?.find((s) => s.name === name || s.url === name);
|
|
217
|
+
if (!stash)
|
|
218
|
+
throw new UsageError(`No git stash found with name "${name}"`);
|
|
219
|
+
if (!GIT_STASH_TYPES.has(stash.type)) {
|
|
220
|
+
throw new UsageError(`Stash "${name}" is not a git stash (type: ${stash.type})`);
|
|
221
|
+
}
|
|
222
|
+
if (!stash.url)
|
|
223
|
+
throw new UsageError(`Stash "${name}" has no URL configured`);
|
|
224
|
+
const repo = parseGitRepoUrl(stash.url);
|
|
225
|
+
repoDir = getCachePaths(repo.canonicalUrl).repoDir;
|
|
226
|
+
writable = stash.writable === true;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
repoDir = resolveStashDir({ readOnly: true });
|
|
230
|
+
// Allow caller to override writable for the primary stash (e.g. from root config.writable)
|
|
231
|
+
if (writableOverride !== undefined) {
|
|
232
|
+
writable = writableOverride;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// No-op: not a git repo
|
|
236
|
+
if (!fs.existsSync(path.join(repoDir, ".git"))) {
|
|
237
|
+
return { committed: false, pushed: false, skipped: true, reason: "not a git repository", output: "" };
|
|
238
|
+
}
|
|
239
|
+
// Nothing to commit?
|
|
240
|
+
const statusResult = spawnSync("git", ["-C", repoDir, "status", "--porcelain"], { encoding: "utf8" });
|
|
241
|
+
if (statusResult.error || statusResult.status !== 0) {
|
|
242
|
+
throw new Error(`git status failed: ${statusResult.error?.message || statusResult.stderr?.trim() || "unknown error"}`);
|
|
243
|
+
}
|
|
244
|
+
if (!statusResult.stdout.trim()) {
|
|
245
|
+
return { committed: false, pushed: false, skipped: false, output: "nothing to commit, working tree clean" };
|
|
112
246
|
}
|
|
113
|
-
|
|
114
|
-
|
|
247
|
+
// Stage and commit — supply fallback identity so fresh environments without
|
|
248
|
+
// user.name/user.email configured can always commit to the default stash.
|
|
249
|
+
const addResult = spawnSync("git", ["-C", repoDir, "add", "-A"], { encoding: "utf8" });
|
|
250
|
+
if (addResult.status !== 0) {
|
|
251
|
+
throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
|
|
115
252
|
}
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
throw new
|
|
253
|
+
const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", commitMessage], { encoding: "utf8" });
|
|
254
|
+
if (commitResult.status !== 0) {
|
|
255
|
+
throw new Error(`git commit failed: ${commitResult.stderr?.trim() || "unknown error"}`);
|
|
119
256
|
}
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
ref = sanitizeString(segments.slice(3).join("/"), 255) || "main";
|
|
257
|
+
// Push only when there is a remote AND the stash is marked writable
|
|
258
|
+
const remoteResult = spawnSync("git", ["-C", repoDir, "remote"], { encoding: "utf8" });
|
|
259
|
+
if (remoteResult.status !== 0) {
|
|
260
|
+
throw new Error(`git remote failed: ${remoteResult.stderr?.trim() || "unknown error"}`);
|
|
125
261
|
}
|
|
126
|
-
|
|
127
|
-
|
|
262
|
+
const hasRemote = remoteResult.stdout.trim().length > 0;
|
|
263
|
+
if (!hasRemote || !writable) {
|
|
264
|
+
return { committed: true, pushed: false, skipped: false, output: commitResult.stdout.trim() };
|
|
128
265
|
}
|
|
129
|
-
|
|
130
|
-
|
|
266
|
+
const pushResult = spawnSync("git", ["-C", repoDir, "push"], { encoding: "utf8", timeout: 120_000 });
|
|
267
|
+
if (pushResult.status !== 0) {
|
|
268
|
+
throw new Error(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`);
|
|
131
269
|
}
|
|
132
270
|
return {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
271
|
+
committed: true,
|
|
272
|
+
pushed: true,
|
|
273
|
+
skipped: false,
|
|
274
|
+
output: (commitResult.stdout + pushResult.stdout).trim() || "changes committed and pushed",
|
|
137
275
|
};
|
|
138
276
|
}
|
|
139
277
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
package/dist/stash-show.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { loadConfig } from "./config";
|
|
3
4
|
import { closeDatabase, openDatabase } from "./db";
|
|
@@ -13,6 +14,35 @@ import { resolveAssetPath } from "./stash-resolve";
|
|
|
13
14
|
import { insertUsageEvent } from "./usage-events";
|
|
14
15
|
// Eagerly import stash providers to trigger self-registration
|
|
15
16
|
import "./stash-providers/index";
|
|
17
|
+
/**
|
|
18
|
+
* Show a wiki root (no page path) — returns the same payload as
|
|
19
|
+
* `akm wiki show <name>`.
|
|
20
|
+
*
|
|
21
|
+
* Called when `parseAssetRef` yields `type === "wiki"` and the name has no
|
|
22
|
+
* `/`, e.g. `wiki:research`.
|
|
23
|
+
*/
|
|
24
|
+
async function showWikiRoot(stashDir, wikiName) {
|
|
25
|
+
const { showWiki, resolveWikiDir } = await import("./wiki.js");
|
|
26
|
+
const wikiDir = resolveWikiDir(stashDir, wikiName);
|
|
27
|
+
if (!fs.existsSync(wikiDir)) {
|
|
28
|
+
throw new NotFoundError(`Wiki not found: ${wikiName}. Run \`akm wiki create ${wikiName}\` to create it.`);
|
|
29
|
+
}
|
|
30
|
+
const result = showWiki(stashDir, wikiName);
|
|
31
|
+
// Shape the WikiShowResult into a ShowResponse-compatible object.
|
|
32
|
+
// The payload mirrors what `akm wiki show <name>` returns.
|
|
33
|
+
return {
|
|
34
|
+
type: "wiki",
|
|
35
|
+
name: result.ref,
|
|
36
|
+
path: result.path,
|
|
37
|
+
...(result.description ? { description: result.description } : {}),
|
|
38
|
+
origin: null,
|
|
39
|
+
editable: false,
|
|
40
|
+
pages: result.pages,
|
|
41
|
+
raws: result.raws,
|
|
42
|
+
...(result.lastModified ? { lastModified: result.lastModified } : {}),
|
|
43
|
+
recentLog: result.recentLog,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
16
46
|
/**
|
|
17
47
|
* Unified show: tries local FTS5 index first, then remote providers.
|
|
18
48
|
*
|
|
@@ -21,6 +51,30 @@ import "./stash-providers/index";
|
|
|
21
51
|
*/
|
|
22
52
|
export async function akmShowUnified(input) {
|
|
23
53
|
const ref = input.ref.trim();
|
|
54
|
+
// 0. Wiki-root shortcut: `wiki:<name>` with no page path routes to the
|
|
55
|
+
// wiki summary (same payload as `akm wiki show <name>`). Honour
|
|
56
|
+
// `parsed.origin` by resolving against the matching stash source(s),
|
|
57
|
+
// falling back to the primary stash when no origin is given.
|
|
58
|
+
{
|
|
59
|
+
const parsed = parseAssetRef(ref);
|
|
60
|
+
if (parsed.type === "wiki" && !parsed.name.includes("/")) {
|
|
61
|
+
const allSources = resolveStashSources();
|
|
62
|
+
const searchSources = resolveSourcesForOrigin(parsed.origin, allSources);
|
|
63
|
+
let lastError;
|
|
64
|
+
for (const source of searchSources) {
|
|
65
|
+
try {
|
|
66
|
+
return await showWikiRoot(source.path, parsed.name);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (!(err instanceof NotFoundError))
|
|
70
|
+
throw err;
|
|
71
|
+
lastError = err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw (lastError ??
|
|
75
|
+
new NotFoundError(`Wiki not found: ${parsed.name}. Run \`akm wiki create ${parsed.name}\` to create it.`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
24
78
|
// 1. Try local filesystem first (FTS5 index lookup)
|
|
25
79
|
let localError;
|
|
26
80
|
try {
|
|
@@ -127,7 +181,7 @@ export async function showLocal(input) {
|
|
|
127
181
|
if (!renderer) {
|
|
128
182
|
throw new UsageError(`Renderer "${match.renderer}" not found for asset: ${displayType}:${parsed.name}`);
|
|
129
183
|
}
|
|
130
|
-
const renderCtx = buildRenderContext(fileCtx, match, allStashDirs);
|
|
184
|
+
const renderCtx = buildRenderContext(fileCtx, match, allStashDirs, source?.registryId);
|
|
131
185
|
const response = renderer.buildShowResponse(renderCtx);
|
|
132
186
|
const editable = isEditable(assetPath, config);
|
|
133
187
|
const fullResponse = {
|
|
@@ -188,6 +242,7 @@ function buildSummaryResponse(full, assetPath) {
|
|
|
188
242
|
...(description ? { description } : {}),
|
|
189
243
|
...(tags && tags.length > 0 ? { tags } : {}),
|
|
190
244
|
...(full.parameters ? { parameters: full.parameters } : {}),
|
|
245
|
+
...(full.workflowTitle ? { workflowTitle: full.workflowTitle } : {}),
|
|
191
246
|
...(full.action ? { action: full.action } : {}),
|
|
192
247
|
...(full.run ? { run: full.run } : {}),
|
|
193
248
|
...(full.origin !== undefined ? { origin: full.origin } : {}),
|
|
@@ -11,12 +11,16 @@ import { resolveStashSources } from "./search-source";
|
|
|
11
11
|
* (e.g. "openviking").
|
|
12
12
|
*/
|
|
13
13
|
export function addStash(opts) {
|
|
14
|
-
const { target, name, providerType, options: providerOptions } = opts;
|
|
14
|
+
const { target, name, providerType, options: providerOptions, writable } = opts;
|
|
15
15
|
const config = loadUserConfig();
|
|
16
16
|
const stashes = [...(config.stashes ?? [])];
|
|
17
|
-
const
|
|
17
|
+
const isRemoteUrl = target.startsWith("http://") ||
|
|
18
|
+
target.startsWith("https://") ||
|
|
19
|
+
target.startsWith("git@") ||
|
|
20
|
+
target.startsWith("ssh://") ||
|
|
21
|
+
target.startsWith("git://");
|
|
18
22
|
let entry;
|
|
19
|
-
if (
|
|
23
|
+
if (isRemoteUrl) {
|
|
20
24
|
if (!providerType) {
|
|
21
25
|
throw new UsageError("--provider is required for URL sources (e.g. --provider openviking)");
|
|
22
26
|
}
|
|
@@ -27,6 +31,8 @@ export function addStash(opts) {
|
|
|
27
31
|
entry = { type: providerType, url: target };
|
|
28
32
|
if (name)
|
|
29
33
|
entry.name = name;
|
|
34
|
+
if (writable)
|
|
35
|
+
entry.writable = true;
|
|
30
36
|
if (providerOptions)
|
|
31
37
|
entry.options = providerOptions;
|
|
32
38
|
}
|
|
@@ -51,7 +57,11 @@ export function addStash(opts) {
|
|
|
51
57
|
export function removeStash(target) {
|
|
52
58
|
const config = loadUserConfig();
|
|
53
59
|
const stashes = [...(config.stashes ?? [])];
|
|
54
|
-
const isUrl = target.startsWith("http://") ||
|
|
60
|
+
const isUrl = target.startsWith("http://") ||
|
|
61
|
+
target.startsWith("https://") ||
|
|
62
|
+
target.startsWith("git@") ||
|
|
63
|
+
target.startsWith("ssh://") ||
|
|
64
|
+
target.startsWith("git://");
|
|
55
65
|
const resolvedPath = !isUrl ? path.resolve(target) : undefined;
|
|
56
66
|
// Try URL match first, then path, then name (most specific → least specific)
|
|
57
67
|
let idx = -1;
|