promptdock 0.1.0
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 +117 -0
- package/dist/api.d.ts +25 -0
- package/dist/api.js +100 -0
- package/dist/args.d.ts +18 -0
- package/dist/args.js +103 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.js +135 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.js +210 -0
- package/dist/commands/lifecycle.d.ts +6 -0
- package/dist/commands/lifecycle.js +257 -0
- package/dist/commands/login.d.ts +4 -0
- package/dist/commands/login.js +58 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +64 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +61 -0
- package/dist/contract.d.ts +62 -0
- package/dist/contract.js +4 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +72 -0
- package/dist/generated/constants.d.ts +9 -0
- package/dist/generated/constants.js +11 -0
- package/dist/help.d.ts +2 -0
- package/dist/help.js +84 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +72 -0
- package/dist/installer.d.ts +43 -0
- package/dist/installer.js +206 -0
- package/dist/integrity.d.ts +4 -0
- package/dist/integrity.js +11 -0
- package/dist/paths.d.ts +5 -0
- package/dist/paths.js +63 -0
- package/dist/receipts.d.ts +58 -0
- package/dist/receipts.js +121 -0
- package/dist/ref.d.ts +11 -0
- package/dist/ref.js +32 -0
- package/dist/registry.d.ts +43 -0
- package/dist/registry.js +111 -0
- package/dist/ui.d.ts +22 -0
- package/dist/ui.js +58 -0
- package/dist/verdicts.d.ts +4 -0
- package/dist/verdicts.js +26 -0
- package/package.json +23 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// `promptdock install <ref>` — resolve → verdict → target → confirm → two-phase
|
|
2
|
+
// install. D-UX11 contracts: non-TTY needs --target/--dir (+ -y); -y skips the
|
|
3
|
+
// PICKER only, never --force; --dry-run is PURE (resolve only — the E5 mint
|
|
4
|
+
// happens at POST install, which dry-run never calls).
|
|
5
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
6
|
+
import { isAbsolute, resolve as resolvePath } from "node:path";
|
|
7
|
+
import { ensureAuth } from "../auth.js";
|
|
8
|
+
import { CliError, EXIT, usageError } from "../errors.js";
|
|
9
|
+
import { checkInstallResponse, performInstall } from "../installer.js";
|
|
10
|
+
import { formatRef, parseSkillRef } from "../ref.js";
|
|
11
|
+
import { detectTargets, nextStepLine, targetById, targetInstallDir, TARGETS, } from "../registry.js";
|
|
12
|
+
import { readReceipt } from "../receipts.js";
|
|
13
|
+
import { colors, confirm, formatBytes, promptSelect } from "../ui.js";
|
|
14
|
+
import { assertInstallable } from "../verdicts.js";
|
|
15
|
+
const NON_TTY_TARGET_HINT = "no install target in a non-interactive session — pass --target <tool> or --dir <path> (e.g. --target claude), plus -y to confirm";
|
|
16
|
+
export async function runInstall(ctx, positionals, flags) {
|
|
17
|
+
const refInput = positionals[0];
|
|
18
|
+
if (!refInput)
|
|
19
|
+
throw usageError("missing skill ref", "usage: promptdock install <handle>/<slug>");
|
|
20
|
+
const ref = parseSkillRef(refInput);
|
|
21
|
+
if (!ref) {
|
|
22
|
+
throw usageError(`"${refInput}" isn't a valid skill ref`, "expected handle/slug (a pasted promptdock.ai skill URL also works)");
|
|
23
|
+
}
|
|
24
|
+
const refString = formatRef(ref);
|
|
25
|
+
const json = flags.json === true;
|
|
26
|
+
const dryRun = flags["dry-run"] === true;
|
|
27
|
+
const c = colors(ctx.env, ctx.io.isTTY);
|
|
28
|
+
const { api } = await ensureAuth(ctx);
|
|
29
|
+
const resolve = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(refString)}`);
|
|
30
|
+
assertInstallable(resolve, refString);
|
|
31
|
+
if (typeof resolve.version_id !== "string" || typeof resolve.skill_id !== "string") {
|
|
32
|
+
throw new CliError("malformed resolve response — update the CLI and retry", EXIT.DENIED, {
|
|
33
|
+
footer: "verdict",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// ── target resolution (D-UX11) ─────────────────────────────────────────────
|
|
37
|
+
const picked = await resolveTarget(ctx, flags, ref);
|
|
38
|
+
// ── receipt awareness (DX3/DX5) ────────────────────────────────────────────
|
|
39
|
+
const existing = readReceipt(picked.dir);
|
|
40
|
+
if (existing.kind === "newer_schema") {
|
|
41
|
+
throw new CliError(`${picked.dir} was installed by a newer promptdock CLI (receipt schema ${existing.schema}). Run: npx promptdock@latest ${ctx.argsLine}`, EXIT.INTEGRITY, { footer: "receipt_schema" });
|
|
42
|
+
}
|
|
43
|
+
const matching = existing.kind === "ok" && existing.receipt.skill_id === resolve.skill_id
|
|
44
|
+
? existing.receipt
|
|
45
|
+
: null;
|
|
46
|
+
const isUpdate = matching !== null;
|
|
47
|
+
if (!isUpdate && !dryRun && flags.force !== true && dirIsNonEmpty(picked.dir)) {
|
|
48
|
+
// The ONLY error whose remedy is --force (DX3).
|
|
49
|
+
const what = existing.kind === "ok" ? `a different skill (${existing.receipt.ref})` : "files";
|
|
50
|
+
throw new CliError(`${picked.dir} already exists and contains ${what} — pass --force to replace it`, EXIT.IO, { footer: "existing_dir" });
|
|
51
|
+
}
|
|
52
|
+
if (isUpdate && matching.version_id === resolve.version_id && flags.force !== true && !dryRun) {
|
|
53
|
+
ctx.io.out(json
|
|
54
|
+
? JSON.stringify({ installed: false, up_to_date: true, version: matching.version, path: picked.dir })
|
|
55
|
+
: `"${resolve.title ?? refString}" is already up to date (v${matching.version}) at ${picked.dir} — use --force to reinstall.`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// ── summary block (D-UX11 confirm-before-write) ────────────────────────────
|
|
59
|
+
const summary = [];
|
|
60
|
+
if (isUpdate) {
|
|
61
|
+
summary.push(`Update ${c.bold(`"${resolve.title ?? refString}"`)} by @${resolve.author ?? ref.handle}`);
|
|
62
|
+
summary.push(` version: v${matching.version} → v${resolve.version}`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
summary.push(`Install ${c.bold(`"${resolve.title ?? refString}"`)} by @${resolve.author ?? ref.handle}`);
|
|
66
|
+
summary.push(` version: v${resolve.version}`);
|
|
67
|
+
}
|
|
68
|
+
summary.push(` files: ${resolve.file_count ?? "?"} files · ${formatBytes(Number(resolve.total_bytes) || 0)}`);
|
|
69
|
+
if (resolve.license)
|
|
70
|
+
summary.push(` license: ${resolve.license}`);
|
|
71
|
+
summary.push(` access: ${resolve.is_free === false ? "premium" : "free"}`);
|
|
72
|
+
summary.push(` to: ${picked.dir}`);
|
|
73
|
+
if (dryRun) {
|
|
74
|
+
if (json) {
|
|
75
|
+
ctx.io.out(JSON.stringify({
|
|
76
|
+
dry_run: true,
|
|
77
|
+
ref: refString,
|
|
78
|
+
skill_id: resolve.skill_id,
|
|
79
|
+
version_id: resolve.version_id,
|
|
80
|
+
version: resolve.version,
|
|
81
|
+
title: resolve.title ?? null,
|
|
82
|
+
author: resolve.author ?? null,
|
|
83
|
+
is_free: resolve.is_free ?? null,
|
|
84
|
+
file_count: resolve.file_count ?? null,
|
|
85
|
+
total_bytes: resolve.total_bytes ?? null,
|
|
86
|
+
target: picked.id,
|
|
87
|
+
path: picked.dir,
|
|
88
|
+
update: isUpdate,
|
|
89
|
+
}));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const line of summary)
|
|
93
|
+
ctx.io.out(line);
|
|
94
|
+
ctx.io.out("");
|
|
95
|
+
ctx.io.out(c.dim("Dry run — nothing was installed (the file list is fetched and verified at install)."));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Confirm: -y skips; TTY prompts; non-TTY without -y refuses (D-UX11).
|
|
99
|
+
if (flags.yes !== true) {
|
|
100
|
+
if (!ctx.io.isTTY) {
|
|
101
|
+
throw usageError("confirmation needed in a non-interactive session", "re-run with -y");
|
|
102
|
+
}
|
|
103
|
+
for (const line of summary)
|
|
104
|
+
ctx.io.out(line);
|
|
105
|
+
const go = await confirm(ctx.io, isUpdate ? "Update?" : "Install?", true);
|
|
106
|
+
if (!go) {
|
|
107
|
+
ctx.io.out("Cancelled — nothing installed.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ── two-phase install ──────────────────────────────────────────────────────
|
|
112
|
+
const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolve.skill_id}/install`, { version_id: resolve.version_id });
|
|
113
|
+
const resp = checkInstallResponse(respRaw);
|
|
114
|
+
if (!json) {
|
|
115
|
+
ctx.io.out(`Downloading ${resp.manifest.length} files…`);
|
|
116
|
+
const shown = resp.manifest.slice(0, 8);
|
|
117
|
+
for (const e of shown)
|
|
118
|
+
ctx.io.out(` ${e.path} ${c.dim(`(${formatBytes(e.bytes)})`)}`);
|
|
119
|
+
if (resp.manifest.length > shown.length) {
|
|
120
|
+
ctx.io.out(c.dim(` +${resp.manifest.length - shown.length} more`));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const receipt = await performInstall(ctx, api, {
|
|
124
|
+
resp,
|
|
125
|
+
targetDir: picked.dir,
|
|
126
|
+
targetId: picked.id,
|
|
127
|
+
refString,
|
|
128
|
+
prior: matching ?? undefined,
|
|
129
|
+
});
|
|
130
|
+
if (json) {
|
|
131
|
+
ctx.io.out(JSON.stringify({
|
|
132
|
+
installed: true,
|
|
133
|
+
update: isUpdate,
|
|
134
|
+
ref: refString,
|
|
135
|
+
version: receipt.version,
|
|
136
|
+
version_id: receipt.version_id,
|
|
137
|
+
target: picked.id,
|
|
138
|
+
path: picked.dir,
|
|
139
|
+
files: receipt.files.length,
|
|
140
|
+
}));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
ctx.io.out("");
|
|
144
|
+
ctx.io.out(`${c.green("✓")} ${isUpdate ? "Updated" : "Installed"} "${resp.skill.title}" v${resp.skill.version} → ${picked.dir}`);
|
|
145
|
+
ctx.io.out(nextStepLine(picked.id, resp.skill.slug));
|
|
146
|
+
}
|
|
147
|
+
async function resolveTarget(ctx, flags, ref) {
|
|
148
|
+
const global = flags.global === true;
|
|
149
|
+
if (typeof flags.dir === "string") {
|
|
150
|
+
const dir = isAbsolute(flags.dir) ? flags.dir : resolvePath(ctx.cwd, flags.dir);
|
|
151
|
+
return { id: "custom", dir };
|
|
152
|
+
}
|
|
153
|
+
if (typeof flags.target === "string") {
|
|
154
|
+
const def = targetById(flags.target);
|
|
155
|
+
if (!def) {
|
|
156
|
+
throw usageError(`unknown --target "${flags.target}"`, `known targets: ${TARGETS.map((t) => t.id).join(", ")} (or use --dir <path>)`);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
id: def.id,
|
|
160
|
+
dir: targetInstallDir(def, { cwd: ctx.cwd, home: ctx.home, global, slug: ref.slug }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const detected = detectTargets({ cwd: ctx.cwd, home: ctx.home, global });
|
|
164
|
+
if (!ctx.io.isTTY)
|
|
165
|
+
throw usageError(NON_TTY_TARGET_HINT);
|
|
166
|
+
if (flags.yes === true) {
|
|
167
|
+
// -y skips the PICKER only, and only when detection is unambiguous (D-UX11).
|
|
168
|
+
const found = detected.filter((t) => t.detected);
|
|
169
|
+
if (found.length === 1) {
|
|
170
|
+
return {
|
|
171
|
+
id: found[0].id,
|
|
172
|
+
dir: targetInstallDir(found[0], { cwd: ctx.cwd, home: ctx.home, global, slug: ref.slug }),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
throw usageError(found.length === 0
|
|
176
|
+
? "-y needs a detectable target and none was found — pass --target <tool> or --dir <path>"
|
|
177
|
+
: `-y needs an unambiguous target and ${found.length} tools were detected — pass --target <tool> or --dir <path>`);
|
|
178
|
+
}
|
|
179
|
+
const scopeNote = global ? " (global)" : "";
|
|
180
|
+
const options = detected.map((t) => {
|
|
181
|
+
const dir = targetInstallDir(t, { cwd: ctx.cwd, home: ctx.home, global, slug: ref.slug });
|
|
182
|
+
const tags = [t.detected ? "(detected)" : null, t.experimental ? "(experimental)" : null]
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join(" ");
|
|
185
|
+
return `${t.label.padEnd(16)} ${dir}${tags ? ` ${tags}` : ""}`;
|
|
186
|
+
});
|
|
187
|
+
options.push("Custom path… (type any directory)");
|
|
188
|
+
const idx = await promptSelect(ctx.io, `Where should this skill be installed?${scopeNote}`, options, 0);
|
|
189
|
+
if (idx === options.length - 1) {
|
|
190
|
+
const answer = (await ctx.io.question("Directory: ")).trim();
|
|
191
|
+
if (!answer)
|
|
192
|
+
throw usageError("no directory given");
|
|
193
|
+
return { id: "custom", dir: isAbsolute(answer) ? answer : resolvePath(ctx.cwd, answer) };
|
|
194
|
+
}
|
|
195
|
+
const chosen = detected[idx];
|
|
196
|
+
return {
|
|
197
|
+
id: chosen.id,
|
|
198
|
+
dir: targetInstallDir(chosen, { cwd: ctx.cwd, home: ctx.home, global, slug: ref.slug }),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function dirIsNonEmpty(dir) {
|
|
202
|
+
if (!existsSync(dir))
|
|
203
|
+
return false;
|
|
204
|
+
try {
|
|
205
|
+
return readdirSync(dir).length > 0;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return true; // unreadable = treat as occupied (never clobber blind)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CliContext } from "../context.js";
|
|
2
|
+
type Flags = Record<string, string | boolean>;
|
|
3
|
+
export declare function runUninstall(ctx: CliContext, positionals: string[], flags: Flags): Promise<void>;
|
|
4
|
+
export declare function runUpdate(ctx: CliContext, positionals: string[], flags: Flags): Promise<void>;
|
|
5
|
+
export declare function runList(ctx: CliContext, flags: Flags): Promise<void>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// `uninstall` / `update` / `list` — the receipt-driven local lifecycle (U2).
|
|
2
|
+
// The receipt is the ONLY authority: the CLI never deletes a file no receipt
|
|
3
|
+
// lists, and a modified file blocks destructive ops without --force (DX5).
|
|
4
|
+
import { existsSync, lstatSync, rmdirSync, rmSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
6
|
+
import { ensureAuth } from "../auth.js";
|
|
7
|
+
import { CliError, EXIT, mapFsError, usageError } from "../errors.js";
|
|
8
|
+
import { checkInstallResponse, performInstall } from "../installer.js";
|
|
9
|
+
import { formatRef, parseSkillRef } from "../ref.js";
|
|
10
|
+
import { modifiedFiles, readReceipt, scanReceipts, RECEIPT_NAME, } from "../receipts.js";
|
|
11
|
+
import { colors, confirm } from "../ui.js";
|
|
12
|
+
import { assertInstallable } from "../verdicts.js";
|
|
13
|
+
/** U2/DX2 scope rules: bare = project-local; -g = global; --all = both. */
|
|
14
|
+
function scopeOf(flags) {
|
|
15
|
+
if (flags.all === true)
|
|
16
|
+
return "both";
|
|
17
|
+
return flags.global === true ? "global" : "local";
|
|
18
|
+
}
|
|
19
|
+
function newerSchemaError(dir, schema, argsLine) {
|
|
20
|
+
return new CliError(`${dir} was installed by a newer promptdock CLI (receipt schema ${schema}). Run: npx promptdock@latest ${argsLine}`, EXIT.INTEGRITY, { footer: "receipt_schema" });
|
|
21
|
+
}
|
|
22
|
+
/** Collect the receipts a lifecycle command operates on. */
|
|
23
|
+
function collect(ctx, flags, refArg) {
|
|
24
|
+
if (typeof flags.dir === "string") {
|
|
25
|
+
const dir = isAbsolute(flags.dir) ? flags.dir : resolvePath(ctx.cwd, flags.dir);
|
|
26
|
+
const read = readReceipt(dir);
|
|
27
|
+
if (read.kind === "none") {
|
|
28
|
+
throw new CliError(`no promptdock receipt in ${dir} — nothing to manage there (the CLI only touches files it installed)`, EXIT.DENIED, { footer: "no_receipt" });
|
|
29
|
+
}
|
|
30
|
+
return [{ dir, targetId: "custom", scope: "local", read }];
|
|
31
|
+
}
|
|
32
|
+
const scope = scopeOf(flags);
|
|
33
|
+
const targetId = typeof flags.target === "string" ? flags.target : undefined;
|
|
34
|
+
let items = scanReceipts({ cwd: ctx.cwd, home: ctx.home, scope, targetId });
|
|
35
|
+
if (refArg) {
|
|
36
|
+
const ref = parseSkillRef(refArg);
|
|
37
|
+
if (!ref) {
|
|
38
|
+
throw usageError(`"${refArg}" isn't a valid skill ref`, "expected handle/slug");
|
|
39
|
+
}
|
|
40
|
+
const wanted = formatRef(ref);
|
|
41
|
+
items = items.filter((i) => i.read.kind === "ok" && i.read.receipt.ref === wanted);
|
|
42
|
+
if (items.length === 0) {
|
|
43
|
+
throw new CliError(`${wanted} is not installed in this scope — try -g (global) or --all, and \`npx promptdock@latest list --all\` to see what's installed`, EXIT.DENIED, { footer: "not_installed" });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return items;
|
|
47
|
+
}
|
|
48
|
+
/* ── uninstall ──────────────────────────────────────────────────────────────── */
|
|
49
|
+
export async function runUninstall(ctx, positionals, flags) {
|
|
50
|
+
const refArg = positionals[0];
|
|
51
|
+
if (!refArg && flags.all !== true && typeof flags.dir !== "string") {
|
|
52
|
+
throw usageError("pass a skill ref, --all, or --dir <path>", "usage: promptdock uninstall <handle>/<slug>");
|
|
53
|
+
}
|
|
54
|
+
const c = colors(ctx.env, ctx.io.isTTY);
|
|
55
|
+
const items = collect(ctx, flags, refArg);
|
|
56
|
+
const removed = [];
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
if (item.read.kind === "newer_schema") {
|
|
59
|
+
throw newerSchemaError(item.dir, item.read.schema, ctx.argsLine);
|
|
60
|
+
}
|
|
61
|
+
if (item.read.kind !== "ok") {
|
|
62
|
+
throw new CliError(`${item.dir} has an unreadable receipt — refusing to guess what to delete (remove the directory manually if you're sure)`, EXIT.INTEGRITY, { footer: "receipt_invalid" });
|
|
63
|
+
}
|
|
64
|
+
const receipt = item.read.receipt;
|
|
65
|
+
// DX5 local-edit guard: human work never dies to a default.
|
|
66
|
+
const modified = modifiedFiles(item.dir, receipt).filter((p) => existsSync(join(item.dir, p)));
|
|
67
|
+
if (modified.length > 0 && flags.force !== true) {
|
|
68
|
+
throw new CliError(`local changes in ${modified.length} file(s) under ${item.dir} (${modified.slice(0, 3).join(", ")}${modified.length > 3 ? ", …" : ""}) — copy your changes out first, then re-run with --force`, EXIT.INTEGRITY, { footer: "local_changes" });
|
|
69
|
+
}
|
|
70
|
+
if (flags.yes !== true) {
|
|
71
|
+
if (!ctx.io.isTTY)
|
|
72
|
+
throw usageError("confirmation needed in a non-interactive session", "re-run with -y");
|
|
73
|
+
const go = await confirm(ctx.io, `Uninstall ${receipt.ref} from ${item.dir}?`, false);
|
|
74
|
+
if (!go) {
|
|
75
|
+
ctx.io.out("Skipped.");
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
removeReceiptOwned(item.dir, receipt);
|
|
80
|
+
removed.push({ ref: receipt.ref, dir: item.dir });
|
|
81
|
+
if (flags.json !== true)
|
|
82
|
+
ctx.io.out(`${c.green("✓")} Uninstalled ${receipt.ref} (${item.dir})`);
|
|
83
|
+
}
|
|
84
|
+
if (flags.json === true)
|
|
85
|
+
ctx.io.out(JSON.stringify({ uninstalled: removed }));
|
|
86
|
+
else if (removed.length === 0)
|
|
87
|
+
ctx.io.out("Nothing uninstalled.");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Remove ONLY what the receipt lists (+ the receipt), then prune emptied dirs
|
|
91
|
+
* bottom-up. lstat semantics: a symlink is removed as the LINK — never followed
|
|
92
|
+
* (a link pointing outside the dir must not delete its target's content).
|
|
93
|
+
*/
|
|
94
|
+
function removeReceiptOwned(dir, receipt) {
|
|
95
|
+
for (const f of receipt.files) {
|
|
96
|
+
const abs = join(dir, f.path);
|
|
97
|
+
try {
|
|
98
|
+
const st = lstatSync(abs);
|
|
99
|
+
if (st.isDirectory())
|
|
100
|
+
continue; // a dir where a file should be — leave it
|
|
101
|
+
unlinkSync(abs); // removes files AND symlinks (the link itself)
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (err.code === "ENOENT")
|
|
105
|
+
continue;
|
|
106
|
+
throw mapFsError(err, abs);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
rmSync(join(dir, RECEIPT_NAME), { force: true });
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
throw mapFsError(err, join(dir, RECEIPT_NAME));
|
|
114
|
+
}
|
|
115
|
+
// Prune emptied subdirectories (deepest first), then the skill dir itself.
|
|
116
|
+
const subdirs = new Set();
|
|
117
|
+
for (const f of receipt.files) {
|
|
118
|
+
let d = dirname(f.path);
|
|
119
|
+
while (d !== "." && d !== "/") {
|
|
120
|
+
subdirs.add(d);
|
|
121
|
+
d = dirname(d);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const ordered = [...subdirs].sort((a, b) => b.split("/").length - a.split("/").length);
|
|
125
|
+
for (const rel of ordered) {
|
|
126
|
+
try {
|
|
127
|
+
rmdirSync(join(dir, rel));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* not empty / already gone — fine */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
rmdirSync(dir);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* contains user files — deliberately left in place */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/* ── update ─────────────────────────────────────────────────────────────────── */
|
|
141
|
+
export async function runUpdate(ctx, positionals, flags) {
|
|
142
|
+
const c = colors(ctx.env, ctx.io.isTTY);
|
|
143
|
+
const checkOnly = flags.check === true;
|
|
144
|
+
const json = flags.json === true;
|
|
145
|
+
const items = collect(ctx, flags, positionals[0]);
|
|
146
|
+
if (items.length === 0) {
|
|
147
|
+
ctx.io.out(json ? JSON.stringify({ updated: [], checked: [] }) : "No promptdock skills installed in this scope.");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const { api } = await ensureAuth(ctx);
|
|
151
|
+
const report = [];
|
|
152
|
+
for (const item of items) {
|
|
153
|
+
if (item.read.kind === "newer_schema") {
|
|
154
|
+
throw newerSchemaError(item.dir, item.read.schema, ctx.argsLine);
|
|
155
|
+
}
|
|
156
|
+
if (item.read.kind !== "ok")
|
|
157
|
+
continue;
|
|
158
|
+
const receipt = item.read.receipt;
|
|
159
|
+
let resolved;
|
|
160
|
+
try {
|
|
161
|
+
resolved = await api.request("GET", `/api/v1/cli/skills/resolve?ref=${encodeURIComponent(receipt.ref)}`);
|
|
162
|
+
assertInstallable(resolved, receipt.ref);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const reason = err instanceof CliError ? err.message : String(err);
|
|
166
|
+
report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: null, status: "skipped", reason });
|
|
167
|
+
if (!json)
|
|
168
|
+
ctx.io.out(`${c.yellow("!")} ${receipt.ref}: ${reason}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (resolved.version_id === receipt.version_id) {
|
|
172
|
+
report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: receipt.version, status: "up_to_date" });
|
|
173
|
+
if (!json)
|
|
174
|
+
ctx.io.out(`${c.dim("·")} ${receipt.ref} is up to date (v${receipt.version})`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const toVersion = Number(resolved.version) || 0;
|
|
178
|
+
if (checkOnly) {
|
|
179
|
+
report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "available" });
|
|
180
|
+
if (!json)
|
|
181
|
+
ctx.io.out(`${c.cyan("↑")} ${receipt.ref}: v${receipt.version} → v${toVersion} available`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// DX5 local-edit guard BEFORE any overwrite.
|
|
185
|
+
const modified = modifiedFiles(item.dir, receipt);
|
|
186
|
+
if (modified.length > 0 && flags.force !== true) {
|
|
187
|
+
const reason = `local changes in ${modified.length} file(s) — --force to overwrite`;
|
|
188
|
+
report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason });
|
|
189
|
+
if (!json)
|
|
190
|
+
ctx.io.out(`${c.yellow("!")} ${receipt.ref}: ${reason}`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (flags.yes !== true) {
|
|
194
|
+
if (!ctx.io.isTTY)
|
|
195
|
+
throw usageError("confirmation needed in a non-interactive session", "re-run with -y (or use --check)");
|
|
196
|
+
const go = await confirm(ctx.io, `Update ${receipt.ref} v${receipt.version} → v${toVersion}?`, true);
|
|
197
|
+
if (!go) {
|
|
198
|
+
report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: toVersion, status: "skipped", reason: "declined" });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (typeof resolved.skill_id !== "string" || typeof resolved.version_id !== "string")
|
|
203
|
+
continue;
|
|
204
|
+
const respRaw = await api.request("POST", `/api/v1/cli/skills/${resolved.skill_id}/install`, { version_id: resolved.version_id });
|
|
205
|
+
const resp = checkInstallResponse(respRaw);
|
|
206
|
+
await performInstall(ctx, api, {
|
|
207
|
+
resp,
|
|
208
|
+
targetDir: item.dir,
|
|
209
|
+
targetId: item.targetId,
|
|
210
|
+
refString: receipt.ref,
|
|
211
|
+
prior: receipt,
|
|
212
|
+
});
|
|
213
|
+
report.push({ ref: receipt.ref, dir: item.dir, from: receipt.version, to: resp.skill.version, status: "updated" });
|
|
214
|
+
if (!json)
|
|
215
|
+
ctx.io.out(`${c.green("✓")} ${receipt.ref}: v${receipt.version} → v${resp.skill.version}`);
|
|
216
|
+
}
|
|
217
|
+
if (json)
|
|
218
|
+
ctx.io.out(JSON.stringify({ results: report }));
|
|
219
|
+
}
|
|
220
|
+
/* ── list ───────────────────────────────────────────────────────────────────── */
|
|
221
|
+
export async function runList(ctx, flags) {
|
|
222
|
+
const scope = scopeOf(flags);
|
|
223
|
+
const items = scanReceipts({ cwd: ctx.cwd, home: ctx.home, scope });
|
|
224
|
+
if (flags.json === true) {
|
|
225
|
+
ctx.io.out(JSON.stringify(items.map((i) => ({
|
|
226
|
+
dir: i.dir,
|
|
227
|
+
target: i.targetId,
|
|
228
|
+
scope: i.scope,
|
|
229
|
+
...(i.read.kind === "ok"
|
|
230
|
+
? {
|
|
231
|
+
ref: i.read.receipt.ref,
|
|
232
|
+
version: i.read.receipt.version,
|
|
233
|
+
version_id: i.read.receipt.version_id,
|
|
234
|
+
installed_at: i.read.receipt.installed_at,
|
|
235
|
+
schema: i.read.receipt.schema,
|
|
236
|
+
}
|
|
237
|
+
: { receipt: i.read.kind }),
|
|
238
|
+
}))));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (items.length === 0) {
|
|
242
|
+
ctx.io.out(scope === "local"
|
|
243
|
+
? "No promptdock skills installed in this project. Try -g (global) or --all."
|
|
244
|
+
: "No promptdock skills installed in this scope.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const i of items) {
|
|
248
|
+
if (i.read.kind === "ok") {
|
|
249
|
+
const r = i.read.receipt;
|
|
250
|
+
const when = typeof r.installed_at === "string" ? r.installed_at.slice(0, 10) : "?";
|
|
251
|
+
ctx.io.out(`${r.ref} v${r.version} ${i.targetId} (${i.scope}) ${when} ${i.dir}`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
ctx.io.out(`? ${i.dir} (${i.read.kind === "newer_schema" ? "newer receipt schema — run npx promptdock@latest" : "unreadable receipt"})`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { CliContext } from "../context.js";
|
|
2
|
+
export declare function runLogin(ctx: CliContext, flags: Record<string, string | boolean>): Promise<void>;
|
|
3
|
+
export declare function runLogout(ctx: CliContext): Promise<void>;
|
|
4
|
+
export declare function runWhoami(ctx: CliContext, flags: Record<string, string | boolean>): Promise<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// `promptdock login [--token pdk_…]` — device flow, or store a Settings-minted
|
|
2
|
+
// CI token (DX6). Explicit `login` works in non-TTY too (the device flow needs
|
|
3
|
+
// no keyboard — CI users should prefer PROMPTDOCK_TOKEN, but an explicit
|
|
4
|
+
// invocation shouldn't refuse); the browser only auto-opens on a TTY.
|
|
5
|
+
import { Api } from "../api.js";
|
|
6
|
+
import { deviceFlowLogin } from "../auth.js";
|
|
7
|
+
import { loadConfig, resolveApiBase, saveConfig } from "../config.js";
|
|
8
|
+
import { usageError } from "../errors.js";
|
|
9
|
+
import { colors } from "../ui.js";
|
|
10
|
+
export async function runLogin(ctx, flags) {
|
|
11
|
+
const c = colors(ctx.env, ctx.io.isTTY);
|
|
12
|
+
const config = loadConfig(ctx.home);
|
|
13
|
+
const baseUrl = resolveApiBase(ctx.env, config);
|
|
14
|
+
let token;
|
|
15
|
+
if (typeof flags.token === "string") {
|
|
16
|
+
if (!flags.token.startsWith("pdk_")) {
|
|
17
|
+
throw usageError("--token expects a pdk_… token (Settings → Account → CLI sessions → Generate token)");
|
|
18
|
+
}
|
|
19
|
+
token = flags.token;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
token = await deviceFlowLogin(ctx, baseUrl, { long: false });
|
|
23
|
+
}
|
|
24
|
+
// Verify BEFORE storing (a typo'd --token should fail here, not at first use).
|
|
25
|
+
const api = new Api(ctx, baseUrl, token);
|
|
26
|
+
const who = await api.request("GET", "/api/v1/cli/whoami");
|
|
27
|
+
saveConfig(ctx.home, { ...config, token, api_base: baseUrl }, ctx.platform);
|
|
28
|
+
const name = who.handle ? `@${who.handle}` : "your account";
|
|
29
|
+
ctx.io.out(`${c.green("✓")} Logged in as ${name}${who.role ? ` (${who.role})` : ""}.`);
|
|
30
|
+
if (ctx.env.PROMPTDOCK_TOKEN) {
|
|
31
|
+
ctx.io.out(c.dim("note: PROMPTDOCK_TOKEN is set and overrides the stored login."));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function runLogout(ctx) {
|
|
35
|
+
const config = loadConfig(ctx.home);
|
|
36
|
+
const had = typeof config.token === "string";
|
|
37
|
+
if (had) {
|
|
38
|
+
delete config.token;
|
|
39
|
+
saveConfig(ctx.home, config, ctx.platform);
|
|
40
|
+
}
|
|
41
|
+
ctx.io.out(had ? "Logged out (local token removed)." : "Not logged in — nothing to remove.");
|
|
42
|
+
ctx.io.out("To invalidate the session server-side too: promptdock.ai → Settings → Account → CLI sessions → Revoke.");
|
|
43
|
+
if (ctx.env.PROMPTDOCK_TOKEN) {
|
|
44
|
+
ctx.io.out("note: PROMPTDOCK_TOKEN is set in this environment and still authenticates requests.");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function runWhoami(ctx, flags) {
|
|
48
|
+
const { ensureAuth } = await import("../auth.js");
|
|
49
|
+
const { api } = await ensureAuth(ctx);
|
|
50
|
+
const who = await api.request("GET", "/api/v1/cli/whoami");
|
|
51
|
+
if (flags.json === true) {
|
|
52
|
+
ctx.io.out(JSON.stringify(who));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const name = who.handle ? `@${who.handle}` : "(no handle)";
|
|
56
|
+
const expiry = who.expires_at ? new Date(who.expires_at).toISOString().slice(0, 10) : "unknown";
|
|
57
|
+
ctx.io.out(`Signed in as ${name} · role: ${who.role} · token expires: ${expiry}`);
|
|
58
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const DEFAULT_API_BASE = "https://promptdock.ai";
|
|
2
|
+
export type CliConfig = {
|
|
3
|
+
token?: string;
|
|
4
|
+
api_base?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
export declare function configDir(home: string): string;
|
|
8
|
+
export declare function configPath(home: string): string;
|
|
9
|
+
export declare function loadConfig(home: string): CliConfig;
|
|
10
|
+
/**
|
|
11
|
+
* Write the config with owner-only perms. POSIX: mode 0600 enforced + VERIFIED
|
|
12
|
+
* (fail-closed — DX6). Windows: chmod is a no-op by design; the user profile
|
|
13
|
+
* dir's ACL is the boundary there (documented in the README).
|
|
14
|
+
*/
|
|
15
|
+
export declare function saveConfig(home: string, config: CliConfig, platform: NodeJS.Platform): void;
|
|
16
|
+
/** Env override first (CI contract), then the stored token. Never logged. */
|
|
17
|
+
export declare function resolveToken(env: Record<string, string | undefined>, config: CliConfig): string | null;
|
|
18
|
+
export declare function resolveApiBase(env: Record<string, string | undefined>, config: CliConfig): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ~/.promptdock/config.json — {token, api_base}. chmod 600, FAIL-CLOSED (DX6):
|
|
2
|
+
// if the file can't be locked down to owner-only on a POSIX system, it is
|
|
3
|
+
// removed and the save throws — a world-readable bearer token is worse than a
|
|
4
|
+
// re-login. Env overrides: PROMPTDOCK_TOKEN (CI), PROMPTDOCK_API_BASE.
|
|
5
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { CliError, EXIT } from "./errors.js";
|
|
8
|
+
export const DEFAULT_API_BASE = "https://promptdock.ai";
|
|
9
|
+
export function configDir(home) {
|
|
10
|
+
return join(home, ".promptdock");
|
|
11
|
+
}
|
|
12
|
+
export function configPath(home) {
|
|
13
|
+
return join(configDir(home), "config.json");
|
|
14
|
+
}
|
|
15
|
+
export function loadConfig(home) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(readFileSync(configPath(home), "utf8"));
|
|
18
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Write the config with owner-only perms. POSIX: mode 0600 enforced + VERIFIED
|
|
26
|
+
* (fail-closed — DX6). Windows: chmod is a no-op by design; the user profile
|
|
27
|
+
* dir's ACL is the boundary there (documented in the README).
|
|
28
|
+
*/
|
|
29
|
+
export function saveConfig(home, config, platform) {
|
|
30
|
+
const dir = configDir(home);
|
|
31
|
+
const file = configPath(home);
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
writeFileSync(file, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
35
|
+
if (platform !== "win32") {
|
|
36
|
+
chmodSync(file, 0o600);
|
|
37
|
+
const mode = statSync(file).mode & 0o777;
|
|
38
|
+
if ((mode & 0o077) !== 0) {
|
|
39
|
+
throw new Error(`config file mode is ${mode.toString(8)}, expected 600`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
// Fail closed: never leave a token in a file we couldn't lock down.
|
|
45
|
+
try {
|
|
46
|
+
rmSync(file, { force: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* best effort */
|
|
50
|
+
}
|
|
51
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
52
|
+
throw new CliError(`couldn't secure ${file} (${detail}) — the token was NOT saved. Fix the directory permissions and retry, or use the PROMPTDOCK_TOKEN env variable.`, EXIT.IO, { footer: "config" });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Env override first (CI contract), then the stored token. Never logged. */
|
|
56
|
+
export function resolveToken(env, config) {
|
|
57
|
+
return env.PROMPTDOCK_TOKEN || (typeof config.token === "string" ? config.token : null) || null;
|
|
58
|
+
}
|
|
59
|
+
export function resolveApiBase(env, config) {
|
|
60
|
+
const base = env.PROMPTDOCK_API_BASE ||
|
|
61
|
+
(typeof config.api_base === "string" ? config.api_base : null) ||
|
|
62
|
+
DEFAULT_API_BASE;
|
|
63
|
+
return base.replace(/\/+$/, "");
|
|
64
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** I/O seam — commands never touch process.* directly (unit tests inject fakes). */
|
|
2
|
+
export type CliIo = {
|
|
3
|
+
/** stdout line */
|
|
4
|
+
out: (s: string) => void;
|
|
5
|
+
/** stderr line */
|
|
6
|
+
err: (s: string) => void;
|
|
7
|
+
/** raw stdout write (countdown repaint; no newline) */
|
|
8
|
+
write: (s: string) => void;
|
|
9
|
+
/** true only when stdin AND stdout are interactive (D-UX11 non-TTY contract) */
|
|
10
|
+
isTTY: boolean;
|
|
11
|
+
/** readline question (TTY only — callers must gate on isTTY) */
|
|
12
|
+
question: (prompt: string) => Promise<string>;
|
|
13
|
+
};
|
|
14
|
+
export type CliContext = {
|
|
15
|
+
io: CliIo;
|
|
16
|
+
env: Record<string, string | undefined>;
|
|
17
|
+
cwd: string;
|
|
18
|
+
home: string;
|
|
19
|
+
platform: NodeJS.Platform;
|
|
20
|
+
fetch: typeof fetch;
|
|
21
|
+
/** the CLI's own version (rides X-Promptdock-Cli-Version + the UA) */
|
|
22
|
+
version: string;
|
|
23
|
+
/** the invocation's args, space-joined (X-Promptdock-Cli-Args — 426 remedy echo) */
|
|
24
|
+
argsLine: string;
|
|
25
|
+
now: () => number;
|
|
26
|
+
sleep: (ms: number) => Promise<void>;
|
|
27
|
+
/** best-effort browser open; NEVER throws (failure → the caller printed the URL) */
|
|
28
|
+
openUrl: (url: string) => void;
|
|
29
|
+
};
|
|
30
|
+
/** Read the package's own version (dist/ and src/ both sit one level under the root). */
|
|
31
|
+
export declare function readOwnVersion(): string;
|
|
32
|
+
/** Best-effort platform browser open (darwin `open`, win32 `start`, else xdg-open). */
|
|
33
|
+
export declare function openUrlBestEffort(url: string, platform: NodeJS.Platform): void;
|
|
34
|
+
export declare function realContext(argv: string[]): CliContext;
|