@dreamlogic-ai/cli 1.0.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/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/commands/catalog.d.ts +1 -0
- package/dist/commands/catalog.js +39 -0
- package/dist/commands/helpers.d.ts +2 -0
- package/dist/commands/helpers.js +15 -0
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +138 -0
- package/dist/commands/list.d.ts +1 -0
- package/dist/commands/list.js +31 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.js +70 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +19 -0
- package/dist/commands/rollback.d.ts +1 -0
- package/dist/commands/rollback.js +40 -0
- package/dist/commands/setup-mcp.d.ts +3 -0
- package/dist/commands/setup-mcp.js +204 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +69 -0
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.js +126 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +155 -0
- package/dist/lib/api-client.d.ts +25 -0
- package/dist/lib/api-client.js +132 -0
- package/dist/lib/config.d.ts +17 -0
- package/dist/lib/config.js +121 -0
- package/dist/lib/installer.d.ts +14 -0
- package/dist/lib/installer.js +283 -0
- package/dist/lib/ui.d.ts +39 -0
- package/dist/lib/ui.js +89 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.js +7 -0
- package/package.json +49 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API client — REST-only communication with Skill Server
|
|
3
|
+
* D-04: CLI uses REST exclusively, no MCP SSE
|
|
4
|
+
* R1-05: Request timeouts
|
|
5
|
+
* R1-09: No redirect following
|
|
6
|
+
* R1-11: Download size limit
|
|
7
|
+
* R1-14: Runtime response validation
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
import { CLI_VERSION } from "../types.js";
|
|
11
|
+
const API_TIMEOUT = 30_000; // 30s for API calls
|
|
12
|
+
const DOWNLOAD_TIMEOUT = 300_000; // 5min for downloads
|
|
13
|
+
const MAX_DOWNLOAD_SIZE = 200 * 1024 * 1024; // 200MB
|
|
14
|
+
export class ApiClient {
|
|
15
|
+
baseUrl;
|
|
16
|
+
apiKey;
|
|
17
|
+
constructor(baseUrl, apiKey) {
|
|
18
|
+
this.baseUrl = baseUrl;
|
|
19
|
+
this.apiKey = apiKey;
|
|
20
|
+
}
|
|
21
|
+
async request(path, opts = {}) {
|
|
22
|
+
const url = `${this.baseUrl}${path}`;
|
|
23
|
+
const headers = {
|
|
24
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
25
|
+
"User-Agent": `dreamlogic-cli/${CLI_VERSION}`,
|
|
26
|
+
...(opts.headers || {}),
|
|
27
|
+
};
|
|
28
|
+
const res = await fetch(url, {
|
|
29
|
+
...opts,
|
|
30
|
+
headers,
|
|
31
|
+
redirect: "error", // R1-09: reject redirects
|
|
32
|
+
signal: AbortSignal.timeout(API_TIMEOUT), // R1-05
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
let msg = `HTTP ${res.status}`;
|
|
36
|
+
try {
|
|
37
|
+
const body = (await res.json());
|
|
38
|
+
if (body.error)
|
|
39
|
+
msg = body.error;
|
|
40
|
+
}
|
|
41
|
+
catch { /* ignore parse errors */ }
|
|
42
|
+
throw new ApiError(msg, res.status);
|
|
43
|
+
}
|
|
44
|
+
return res.json();
|
|
45
|
+
}
|
|
46
|
+
/** GET /api/me — verify key + get user info */
|
|
47
|
+
async me() {
|
|
48
|
+
const data = await this.request("/api/me");
|
|
49
|
+
// R1-14: Runtime validation
|
|
50
|
+
if (!data || typeof data.id !== "string" || typeof data.name !== "string") {
|
|
51
|
+
throw new ApiError("Invalid server response: missing user info", 502);
|
|
52
|
+
}
|
|
53
|
+
return data;
|
|
54
|
+
}
|
|
55
|
+
/** GET /skills — list available skills with version info */
|
|
56
|
+
async listSkills() {
|
|
57
|
+
const data = await this.request("/skills");
|
|
58
|
+
// R1-14: Runtime validation
|
|
59
|
+
if (!Array.isArray(data?.skills)) {
|
|
60
|
+
throw new ApiError("Invalid server response: missing skills array", 502);
|
|
61
|
+
}
|
|
62
|
+
return data.skills;
|
|
63
|
+
}
|
|
64
|
+
// R2-11: Removed unused getSkill() method
|
|
65
|
+
/** GET /packages/:filename — download package as buffer (R1-11: size limited) */
|
|
66
|
+
async downloadPackage(filename, onProgress) {
|
|
67
|
+
const url = `${this.baseUrl}/packages/${encodeURIComponent(filename)}`;
|
|
68
|
+
const res = await fetch(url, {
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
71
|
+
"User-Agent": `dreamlogic-cli/${CLI_VERSION}`,
|
|
72
|
+
},
|
|
73
|
+
redirect: "error", // R1-09
|
|
74
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT), // R1-05
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
throw new ApiError(`Download failed: HTTP ${res.status}`, res.status);
|
|
78
|
+
}
|
|
79
|
+
const contentLength = parseInt(res.headers.get("content-length") || "0", 10);
|
|
80
|
+
// R2-14: Guard against NaN/negative Content-Length
|
|
81
|
+
if (!Number.isNaN(contentLength) && contentLength > 0 && contentLength > MAX_DOWNLOAD_SIZE) {
|
|
82
|
+
throw new ApiError(`Package too large: ${contentLength} bytes (max ${MAX_DOWNLOAD_SIZE})`, 413);
|
|
83
|
+
}
|
|
84
|
+
const reader = res.body?.getReader();
|
|
85
|
+
if (!reader)
|
|
86
|
+
throw new ApiError("No response body", 500);
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let downloaded = 0;
|
|
89
|
+
while (true) {
|
|
90
|
+
const { done, value } = await reader.read();
|
|
91
|
+
if (done)
|
|
92
|
+
break;
|
|
93
|
+
chunks.push(value);
|
|
94
|
+
downloaded += value.length;
|
|
95
|
+
// R1-11: Enforce size limit during streaming
|
|
96
|
+
if (downloaded > MAX_DOWNLOAD_SIZE) {
|
|
97
|
+
reader.cancel();
|
|
98
|
+
throw new ApiError("Package too large", 413);
|
|
99
|
+
}
|
|
100
|
+
if (onProgress && contentLength > 0) {
|
|
101
|
+
onProgress(downloaded, contentLength);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const buffer = Buffer.concat(chunks);
|
|
105
|
+
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
106
|
+
return { buffer, sha256 };
|
|
107
|
+
}
|
|
108
|
+
/** GET /health — check server connectivity (R2-09: validated) */
|
|
109
|
+
async health() {
|
|
110
|
+
const url = `${this.baseUrl}/health`;
|
|
111
|
+
const res = await fetch(url, {
|
|
112
|
+
headers: { "User-Agent": `dreamlogic-cli/${CLI_VERSION}` },
|
|
113
|
+
redirect: "error",
|
|
114
|
+
signal: AbortSignal.timeout(5000),
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok)
|
|
117
|
+
throw new ApiError("Server unreachable", res.status);
|
|
118
|
+
const data = await res.json();
|
|
119
|
+
if (typeof data?.status !== "string" || typeof data?.version !== "string") {
|
|
120
|
+
throw new ApiError("Invalid health response", 502);
|
|
121
|
+
}
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export class ApiError extends Error {
|
|
126
|
+
status;
|
|
127
|
+
constructor(message, status) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.status = status;
|
|
130
|
+
this.name = "ApiError";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type CliConfig, type InstalledRegistry } from "../types.js";
|
|
2
|
+
export declare function getDefaultInstallDir(): string;
|
|
3
|
+
export declare function ensureConfigDir(): void;
|
|
4
|
+
export declare function loadConfig(): CliConfig | null;
|
|
5
|
+
export declare function saveConfig(config: CliConfig): void;
|
|
6
|
+
/** R1-06: Validate key format from all sources (env, config file) */
|
|
7
|
+
export declare function getApiKey(): string | null;
|
|
8
|
+
/** R1-07: Enforce HTTPS (localhost/127.0.0.1 exempt for dev) */
|
|
9
|
+
export declare function getServer(): string;
|
|
10
|
+
export declare function getInstallDir(): string;
|
|
11
|
+
export declare function loadInstalled(): InstalledRegistry;
|
|
12
|
+
/** R1-08: Write installed.json with restricted permissions too */
|
|
13
|
+
export declare function saveInstalled(registry: InstalledRegistry): void;
|
|
14
|
+
/** R1-13: Zero-fill before overwrite for defense-in-depth */
|
|
15
|
+
export declare function clearConfig(): void;
|
|
16
|
+
/** Mask API key for display: sk-user-ce40...4d42 */
|
|
17
|
+
export declare function maskKey(key: string): string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration manager — reads/writes ~/.dreamlogic/config.json
|
|
3
|
+
* Key stored with file permissions 600 (owner-only)
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir } from "os";
|
|
8
|
+
import { CONFIG_DIR_NAME, DEFAULT_SERVER, DEFAULT_INSTALL_DIR_NAME, } from "../types.js";
|
|
9
|
+
// R1-06: Key format validation applied everywhere
|
|
10
|
+
const KEY_RE = /^sk-(admin|user)-[a-f0-9]{16,}$/;
|
|
11
|
+
function getConfigDir() {
|
|
12
|
+
return join(homedir(), CONFIG_DIR_NAME);
|
|
13
|
+
}
|
|
14
|
+
function getConfigPath() {
|
|
15
|
+
return join(getConfigDir(), "config.json");
|
|
16
|
+
}
|
|
17
|
+
function getInstalledPath() {
|
|
18
|
+
return join(getConfigDir(), "installed.json");
|
|
19
|
+
}
|
|
20
|
+
export function getDefaultInstallDir() {
|
|
21
|
+
return join(homedir(), DEFAULT_INSTALL_DIR_NAME);
|
|
22
|
+
}
|
|
23
|
+
export function ensureConfigDir() {
|
|
24
|
+
const dir = getConfigDir();
|
|
25
|
+
if (!existsSync(dir)) {
|
|
26
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function loadConfig() {
|
|
30
|
+
const path = getConfigPath();
|
|
31
|
+
if (!existsSync(path))
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
35
|
+
// R2-13: Basic shape validation
|
|
36
|
+
if (typeof data !== "object" || data === null)
|
|
37
|
+
return null;
|
|
38
|
+
if (data.api_key && typeof data.api_key !== "string")
|
|
39
|
+
return null;
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function saveConfig(config) {
|
|
47
|
+
ensureConfigDir();
|
|
48
|
+
const path = getConfigPath();
|
|
49
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
50
|
+
try {
|
|
51
|
+
chmodSync(path, 0o600);
|
|
52
|
+
}
|
|
53
|
+
catch { /* Windows fallback */ }
|
|
54
|
+
}
|
|
55
|
+
/** R1-06: Validate key format from all sources (env, config file) */
|
|
56
|
+
export function getApiKey() {
|
|
57
|
+
const key = process.env.DREAMLOGIC_API_KEY || loadConfig()?.api_key || null;
|
|
58
|
+
if (key && !KEY_RE.test(key)) {
|
|
59
|
+
console.error(" ❌ Invalid API key format (expected sk-user-xxx or sk-admin-xxx)");
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return key;
|
|
63
|
+
}
|
|
64
|
+
/** R1-07: Enforce HTTPS (localhost/127.0.0.1 exempt for dev) */
|
|
65
|
+
export function getServer() {
|
|
66
|
+
const url = process.env.DREAMLOGIC_SERVER || loadConfig()?.server || DEFAULT_SERVER;
|
|
67
|
+
// R1-07 + R2-03: HTTPS enforcement (localhost/127.0.0.1 exempt with strict boundary)
|
|
68
|
+
if (!/^https:\/\//i.test(url) && !/^http:\/\/(localhost|127\.0\.0\.1)(:[0-9]+)?(\/|$)/i.test(url)) {
|
|
69
|
+
throw new Error("Server URL must use HTTPS (http://localhost allowed for dev)");
|
|
70
|
+
}
|
|
71
|
+
return url;
|
|
72
|
+
}
|
|
73
|
+
export function getInstallDir() {
|
|
74
|
+
if (process.env.DREAMLOGIC_INSTALL_DIR)
|
|
75
|
+
return process.env.DREAMLOGIC_INSTALL_DIR;
|
|
76
|
+
const config = loadConfig();
|
|
77
|
+
return config?.install_dir ?? getDefaultInstallDir();
|
|
78
|
+
}
|
|
79
|
+
export function loadInstalled() {
|
|
80
|
+
const path = getInstalledPath();
|
|
81
|
+
if (!existsSync(path))
|
|
82
|
+
return {};
|
|
83
|
+
try {
|
|
84
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
85
|
+
// R2-13: Basic shape validation
|
|
86
|
+
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
87
|
+
return {};
|
|
88
|
+
return data;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** R1-08: Write installed.json with restricted permissions too */
|
|
95
|
+
export function saveInstalled(registry) {
|
|
96
|
+
ensureConfigDir();
|
|
97
|
+
const path = getInstalledPath();
|
|
98
|
+
writeFileSync(path, JSON.stringify(registry, null, 2) + "\n", { mode: 0o600 });
|
|
99
|
+
try {
|
|
100
|
+
chmodSync(path, 0o600);
|
|
101
|
+
}
|
|
102
|
+
catch { /* Windows fallback */ }
|
|
103
|
+
}
|
|
104
|
+
/** R1-13: Zero-fill before overwrite for defense-in-depth */
|
|
105
|
+
export function clearConfig() {
|
|
106
|
+
const path = getConfigPath();
|
|
107
|
+
if (existsSync(path)) {
|
|
108
|
+
try {
|
|
109
|
+
const size = statSync(path).size;
|
|
110
|
+
writeFileSync(path, Buffer.alloc(size, 0));
|
|
111
|
+
}
|
|
112
|
+
catch { /* best-effort */ }
|
|
113
|
+
writeFileSync(path, "{}\n", { mode: 0o600 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Mask API key for display: sk-user-ce40...4d42 */
|
|
117
|
+
export function maskKey(key) {
|
|
118
|
+
if (key.length < 12)
|
|
119
|
+
return "***";
|
|
120
|
+
return key.slice(0, 12) + "..." + key.slice(-4);
|
|
121
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ApiClient } from "./api-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Download and install a skill package
|
|
4
|
+
*/
|
|
5
|
+
export declare function installSkill(client: ApiClient, skillId: string, packageFile: string, expectedSha256: string | undefined, expectedVersion: string, opts?: {
|
|
6
|
+
fromFile?: string;
|
|
7
|
+
}): Promise<{
|
|
8
|
+
path: string;
|
|
9
|
+
version: string;
|
|
10
|
+
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Rollback a skill to its backup (D-13)
|
|
13
|
+
*/
|
|
14
|
+
export declare function rollbackSkill(skillId: string): boolean;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer — download, verify, extract, atomic-swap
|
|
3
|
+
* D-03: Uses yauzl (not adm-zip) for safe ZIP extraction
|
|
4
|
+
* D-05: Atomic directory replacement via staging + rename
|
|
5
|
+
* D-14: SHA256 re-verified on every install (no trusted cache)
|
|
6
|
+
* R1-03: Zip bomb protection (total size + entry count limits)
|
|
7
|
+
* R1-10: SIGINT/SIGTERM cleanup handler
|
|
8
|
+
*/
|
|
9
|
+
import { createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "fs";
|
|
10
|
+
import { join, normalize, resolve as pathResolve } from "path";
|
|
11
|
+
import { pipeline } from "stream/promises";
|
|
12
|
+
import yauzl from "yauzl";
|
|
13
|
+
import { loadInstalled, saveInstalled, getInstallDir } from "./config.js";
|
|
14
|
+
import { ui } from "./ui.js";
|
|
15
|
+
// R1-03: Extraction safety limits
|
|
16
|
+
const MAX_TOTAL_EXTRACT_SIZE = 500 * 1024 * 1024; // 500MB
|
|
17
|
+
const MAX_ENTRY_COUNT = 10_000;
|
|
18
|
+
const MAX_SINGLE_FILE_SIZE = 100 * 1024 * 1024; // 100MB
|
|
19
|
+
// R2-10: Safe skill ID pattern
|
|
20
|
+
const SAFE_SKILL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
21
|
+
/**
|
|
22
|
+
* Download and install a skill package
|
|
23
|
+
*/
|
|
24
|
+
export async function installSkill(client, skillId, packageFile, expectedSha256, expectedVersion, opts = {}) {
|
|
25
|
+
const installDir = getInstallDir();
|
|
26
|
+
mkdirSync(installDir, { recursive: true });
|
|
27
|
+
// R2-10: Validate skill ID to prevent path traversal via malicious server
|
|
28
|
+
if (!SAFE_SKILL_ID.test(skillId)) {
|
|
29
|
+
throw new Error(`Invalid skill ID: ${skillId}`);
|
|
30
|
+
}
|
|
31
|
+
const skillDir = join(installDir, skillId);
|
|
32
|
+
const stagingDir = join(installDir, `.${skillId}-staging-${Date.now()}`);
|
|
33
|
+
const backupDir = join(installDir, `.${skillId}-backup-${Date.now()}`);
|
|
34
|
+
let buffer;
|
|
35
|
+
let actualSha256;
|
|
36
|
+
if (opts.fromFile) {
|
|
37
|
+
// D-10: Offline install from local file
|
|
38
|
+
const { readFileSync } = await import("fs");
|
|
39
|
+
const { createHash } = await import("crypto");
|
|
40
|
+
buffer = readFileSync(opts.fromFile);
|
|
41
|
+
actualSha256 = createHash("sha256").update(buffer).digest("hex");
|
|
42
|
+
ui.info(`Loaded from file: ${opts.fromFile}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// Download with progress
|
|
46
|
+
const spinner = ui.spinner(`Downloading ${skillId} ${expectedVersion}...`);
|
|
47
|
+
spinner.start();
|
|
48
|
+
try {
|
|
49
|
+
const result = await client.downloadPackage(packageFile, (dl, total) => {
|
|
50
|
+
const pct = Math.round((dl / total) * 100);
|
|
51
|
+
const bar = "█".repeat(Math.round(pct / 4)) + "░".repeat(25 - Math.round(pct / 4));
|
|
52
|
+
spinner.text = ` 📦 ${bar} ${pct}% | ${ui.fileSize(dl)}`;
|
|
53
|
+
});
|
|
54
|
+
buffer = result.buffer;
|
|
55
|
+
actualSha256 = result.sha256;
|
|
56
|
+
spinner.succeed(` Downloaded ${ui.fileSize(buffer.length)}`);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
spinner.fail(` Download failed`);
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// SHA256 verification
|
|
64
|
+
if (expectedSha256) {
|
|
65
|
+
if (actualSha256 !== expectedSha256) {
|
|
66
|
+
ui.err("SHA256 mismatch! Package may be corrupted or tampered.");
|
|
67
|
+
ui.line(` Expected: ${expectedSha256}`);
|
|
68
|
+
ui.line(` Actual: ${actualSha256}`);
|
|
69
|
+
throw new Error("SHA256 verification failed");
|
|
70
|
+
}
|
|
71
|
+
ui.ok("SHA256 verified");
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
ui.warning("No SHA256 hash from server — skipping verification");
|
|
75
|
+
}
|
|
76
|
+
// Extract to staging directory (D-05: never extract directly to target)
|
|
77
|
+
// R1-10 + R2-01/R2-02: Signal cleanup with proper listener management
|
|
78
|
+
const cleanupAndExit = (signal) => {
|
|
79
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
80
|
+
// R2-02: Re-raise signal so process actually exits
|
|
81
|
+
process.removeListener("SIGINT", cleanupAndExit);
|
|
82
|
+
process.removeListener("SIGTERM", cleanupAndExit);
|
|
83
|
+
process.kill(process.pid, signal);
|
|
84
|
+
};
|
|
85
|
+
process.on("SIGINT", cleanupAndExit);
|
|
86
|
+
process.on("SIGTERM", cleanupAndExit);
|
|
87
|
+
// R2-01: try/finally ensures listeners are always removed
|
|
88
|
+
try {
|
|
89
|
+
const extractSpinner = ui.spinner("Extracting...");
|
|
90
|
+
extractSpinner.start();
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(stagingDir, { recursive: true });
|
|
93
|
+
await extractZip(buffer, stagingDir);
|
|
94
|
+
extractSpinner.succeed(" Extracted");
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
extractSpinner.fail(" Extraction failed");
|
|
98
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
// Atomic swap (D-05)
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(skillDir)) {
|
|
104
|
+
renameSync(skillDir, backupDir);
|
|
105
|
+
ui.info(`Backed up previous version`);
|
|
106
|
+
}
|
|
107
|
+
const extractedRoot = findExtractedRoot(stagingDir);
|
|
108
|
+
renameSync(extractedRoot, skillDir);
|
|
109
|
+
if (existsSync(stagingDir)) {
|
|
110
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
if (existsSync(backupDir) && !existsSync(skillDir)) {
|
|
115
|
+
renameSync(backupDir, skillDir);
|
|
116
|
+
}
|
|
117
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
// R2-01: Always remove listeners, even on error
|
|
123
|
+
process.removeListener("SIGINT", cleanupAndExit);
|
|
124
|
+
process.removeListener("SIGTERM", cleanupAndExit);
|
|
125
|
+
}
|
|
126
|
+
// Update installed registry
|
|
127
|
+
const installed = loadInstalled();
|
|
128
|
+
const previousVersion = installed[skillId]?.version;
|
|
129
|
+
const entry = {
|
|
130
|
+
version: expectedVersion,
|
|
131
|
+
installed_at: new Date().toISOString(),
|
|
132
|
+
path: skillDir,
|
|
133
|
+
sha256: actualSha256,
|
|
134
|
+
...(previousVersion ? { previous_version: previousVersion } : {}),
|
|
135
|
+
};
|
|
136
|
+
installed[skillId] = entry;
|
|
137
|
+
saveInstalled(installed);
|
|
138
|
+
// R2-12: Clean up old backups (keep only the latest)
|
|
139
|
+
try {
|
|
140
|
+
const oldBackups = readdirSync(installDir)
|
|
141
|
+
.filter((e) => e.startsWith(`.${skillId}-backup-`))
|
|
142
|
+
.sort()
|
|
143
|
+
.slice(0, -1); // keep newest
|
|
144
|
+
for (const b of oldBackups) {
|
|
145
|
+
rmSync(join(installDir, b), { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch { /* best-effort cleanup */ }
|
|
149
|
+
if (existsSync(backupDir)) {
|
|
150
|
+
ui.info(`Rollback available: dreamlogic rollback ${skillId}`);
|
|
151
|
+
}
|
|
152
|
+
return { path: skillDir, version: expectedVersion };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Rollback a skill to its backup (D-13)
|
|
156
|
+
*/
|
|
157
|
+
export function rollbackSkill(skillId) {
|
|
158
|
+
const installDir = getInstallDir();
|
|
159
|
+
const skillDir = join(installDir, skillId);
|
|
160
|
+
let entries;
|
|
161
|
+
try {
|
|
162
|
+
entries = readdirSync(installDir);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const backups = entries
|
|
168
|
+
.filter((e) => e.startsWith(`.${skillId}-backup-`))
|
|
169
|
+
.sort()
|
|
170
|
+
.reverse();
|
|
171
|
+
if (backups.length === 0)
|
|
172
|
+
return false;
|
|
173
|
+
const latestBackup = join(installDir, backups[0]);
|
|
174
|
+
if (existsSync(skillDir)) {
|
|
175
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
renameSync(latestBackup, skillDir);
|
|
178
|
+
// Update installed.json
|
|
179
|
+
const installed = loadInstalled();
|
|
180
|
+
if (installed[skillId]?.previous_version) {
|
|
181
|
+
installed[skillId].version = installed[skillId].previous_version;
|
|
182
|
+
delete installed[skillId].previous_version;
|
|
183
|
+
installed[skillId].installed_at = new Date().toISOString();
|
|
184
|
+
saveInstalled(installed);
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Find the actual root directory inside extracted ZIP
|
|
190
|
+
* (handles single-directory-wrapper pattern like suno-cover-v5-v20260406/)
|
|
191
|
+
*/
|
|
192
|
+
function findExtractedRoot(stagingDir) {
|
|
193
|
+
const entries = readdirSync(stagingDir);
|
|
194
|
+
// If only one directory entry, use that as root
|
|
195
|
+
if (entries.length === 1) {
|
|
196
|
+
const single = join(stagingDir, entries[0]);
|
|
197
|
+
try {
|
|
198
|
+
if (statSync(single).isDirectory())
|
|
199
|
+
return single;
|
|
200
|
+
}
|
|
201
|
+
catch { /* fallthrough */ }
|
|
202
|
+
}
|
|
203
|
+
return stagingDir;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Safe ZIP extraction using yauzl (D-03)
|
|
207
|
+
* Prevents: path traversal, symlinks, oversized files
|
|
208
|
+
* R1-03: Total size limit + entry count limit (zip bomb protection)
|
|
209
|
+
*/
|
|
210
|
+
function extractZip(buffer, targetDir) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
yauzl.fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => {
|
|
213
|
+
if (err || !zipfile) {
|
|
214
|
+
reject(err || new Error("Failed to open ZIP"));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
let totalExtracted = 0;
|
|
218
|
+
let entryCount = 0;
|
|
219
|
+
zipfile.readEntry();
|
|
220
|
+
zipfile.on("entry", (entry) => {
|
|
221
|
+
// R1-03: Entry count limit
|
|
222
|
+
entryCount++;
|
|
223
|
+
if (entryCount > MAX_ENTRY_COUNT) {
|
|
224
|
+
zipfile.close(); // R2-08: Close before reject
|
|
225
|
+
reject(new Error(`Too many ZIP entries (>${MAX_ENTRY_COUNT}) — possible zip bomb`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const entryPath = normalize(entry.fileName);
|
|
229
|
+
// D-03 / Security: reject path traversal
|
|
230
|
+
if (entryPath.startsWith("..") || entryPath.includes("/../")) {
|
|
231
|
+
zipfile.close(); // R2-08
|
|
232
|
+
reject(new Error(`Unsafe path in ZIP: ${entry.fileName}`));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Reject oversized single files
|
|
236
|
+
if (entry.uncompressedSize > MAX_SINGLE_FILE_SIZE) {
|
|
237
|
+
zipfile.close(); // R2-08
|
|
238
|
+
reject(new Error(`File too large in ZIP: ${entry.fileName} (${entry.uncompressedSize} bytes)`));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// R1-03: Cumulative size limit
|
|
242
|
+
totalExtracted += entry.uncompressedSize;
|
|
243
|
+
if (totalExtracted > MAX_TOTAL_EXTRACT_SIZE) {
|
|
244
|
+
zipfile.close(); // R2-08
|
|
245
|
+
reject(new Error(`Total extracted size exceeds ${MAX_TOTAL_EXTRACT_SIZE / 1024 / 1024}MB — possible zip bomb`));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const fullPath = join(targetDir, entryPath);
|
|
249
|
+
const resolvedPath = pathResolve(fullPath);
|
|
250
|
+
// R2-04: Trailing separator prevents prefix collision
|
|
251
|
+
const resolvedTarget = pathResolve(targetDir) + "/";
|
|
252
|
+
if (!resolvedPath.startsWith(resolvedTarget) && resolvedPath !== pathResolve(targetDir)) {
|
|
253
|
+
zipfile.close(); // R2-08
|
|
254
|
+
reject(new Error(`Path traversal detected: ${entry.fileName}`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (entry.fileName.endsWith("/")) {
|
|
258
|
+
// Directory
|
|
259
|
+
mkdirSync(fullPath, { recursive: true });
|
|
260
|
+
zipfile.readEntry();
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// File
|
|
264
|
+
mkdirSync(join(fullPath, ".."), { recursive: true });
|
|
265
|
+
zipfile.openReadStream(entry, (err, readStream) => {
|
|
266
|
+
if (err || !readStream) {
|
|
267
|
+
zipfile.close(); // R2-08
|
|
268
|
+
reject(err || new Error("Failed to read ZIP entry"));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const writeStream = createWriteStream(fullPath);
|
|
272
|
+
pipeline(readStream, writeStream).then(() => zipfile.readEntry()).catch((e) => {
|
|
273
|
+
zipfile.close(); // R2-08
|
|
274
|
+
reject(e);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
zipfile.on("end", () => resolve());
|
|
280
|
+
zipfile.on("error", reject);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
package/dist/lib/ui.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Ora } from "ora";
|
|
2
|
+
export declare const ui: {
|
|
3
|
+
brand: import("chalk").ChalkInstance;
|
|
4
|
+
accent: import("chalk").ChalkInstance;
|
|
5
|
+
success: import("chalk").ChalkInstance;
|
|
6
|
+
warn: import("chalk").ChalkInstance;
|
|
7
|
+
error: import("chalk").ChalkInstance;
|
|
8
|
+
dim: import("chalk").ChalkInstance;
|
|
9
|
+
/** Print the welcome banner */
|
|
10
|
+
banner(): void;
|
|
11
|
+
/** Print a section header */
|
|
12
|
+
header(text: string): void;
|
|
13
|
+
/** Print success message */
|
|
14
|
+
ok(msg: string): void;
|
|
15
|
+
/** Print warning */
|
|
16
|
+
warning(msg: string): void;
|
|
17
|
+
/** Print error */
|
|
18
|
+
err(msg: string): void;
|
|
19
|
+
/** Print info line */
|
|
20
|
+
info(msg: string): void;
|
|
21
|
+
/** Indented line */
|
|
22
|
+
line(msg: string): void;
|
|
23
|
+
/** Create a spinner */
|
|
24
|
+
spinner(text: string): Ora;
|
|
25
|
+
/** Format file size */
|
|
26
|
+
fileSize(bytes: number): string;
|
|
27
|
+
/** Format a skill for display */
|
|
28
|
+
skillLine(s: {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
version?: string;
|
|
32
|
+
description: string;
|
|
33
|
+
tag?: string;
|
|
34
|
+
}): string;
|
|
35
|
+
/** Print a key-value table */
|
|
36
|
+
table(rows: [string, string][]): void;
|
|
37
|
+
/** Goodbye message */
|
|
38
|
+
goodbye(): void;
|
|
39
|
+
};
|