@nucleoicons/skills 0.1.3 → 0.1.4

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/api.d.ts CHANGED
@@ -32,6 +32,10 @@ export interface SkillsReleaseData {
32
32
  files: SkillsReleaseFile[];
33
33
  message?: string;
34
34
  }
35
+ export interface DownloadProgress {
36
+ transferred: number;
37
+ total: number | null;
38
+ }
35
39
  /**
36
40
  * POST /skills/login
37
41
  * Body: { email, password }
@@ -43,5 +47,10 @@ export declare function skillsLogin(email: string, password: string): Promise<Sk
43
47
  * Response: { data: { version, families, files, downloadReady } }
44
48
  */
45
49
  export declare function fetchSkillsRelease(token: string, target: PackageTarget): Promise<SkillsReleaseData>;
46
- /** Download a remote file (signed R2 URL) to disk. */
47
- export declare function downloadUrlToFile(url: string, destPath: string): Promise<void>;
50
+ declare function formatBytes(bytes: number): string;
51
+ /** Download a remote file (signed R2 URL) to disk, with optional progress. */
52
+ export declare function downloadUrlToFile(url: string, destPath: string, onProgress?: (progress: DownloadProgress) => void): Promise<{
53
+ bytes: number;
54
+ total: number | null;
55
+ }>;
56
+ export { formatBytes };
package/dist/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createWriteStream } from "node:fs";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
+ import { Transform } from "node:stream";
4
5
  import { pipeline } from "node:stream/promises";
5
6
  import { Readable } from "node:stream";
6
7
  import { getApiBaseUrl } from "./config.js";
@@ -84,18 +85,58 @@ export async function fetchSkillsRelease(token, target) {
84
85
  }
85
86
  return json.data;
86
87
  }
87
- /** Download a remote file (signed R2 URL) to disk. */
88
- export async function downloadUrlToFile(url, destPath) {
89
- const res = await fetch(url);
88
+ function formatBytes(bytes) {
89
+ if (bytes < 1024) {
90
+ return `${bytes} B`;
91
+ }
92
+ if (bytes < 1024 * 1024) {
93
+ return `${(bytes / 1024).toFixed(1)} KB`;
94
+ }
95
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
96
+ }
97
+ /** Download a remote file (signed R2 URL) to disk, with optional progress. */
98
+ export async function downloadUrlToFile(url, destPath, onProgress) {
99
+ let res;
100
+ try {
101
+ res = await fetch(url);
102
+ }
103
+ catch (error) {
104
+ const detail = error instanceof Error ? error.message : String(error);
105
+ throw new ApiError(`Download failed (network): ${detail}`);
106
+ }
90
107
  if (!res.ok) {
91
- throw new ApiError(`Download failed (${res.status}) for ${url}`, res.status);
108
+ throw new ApiError(`Download failed (${res.status}). The link may have expired — run setup again.`, res.status);
92
109
  }
93
110
  if (!res.body) {
94
111
  throw new ApiError("Download failed: empty response body.");
95
112
  }
113
+ const totalHeader = res.headers.get("content-length");
114
+ const total = totalHeader ? Number(totalHeader) : null;
115
+ let transferred = 0;
116
+ let lastLogged = 0;
96
117
  await mkdir(dirname(destPath), { recursive: true });
97
118
  const fileStream = createWriteStream(destPath);
98
- // Node fetch body → Web ReadableStream; convert for pipeline
99
- const nodeReadable = Readable.fromWeb(res.body);
100
- await pipeline(nodeReadable, fileStream);
119
+ const progressTap = new Transform({
120
+ transform(chunk, _encoding, callback) {
121
+ transferred += chunk.length;
122
+ if (onProgress) {
123
+ onProgress({ transferred, total: Number.isFinite(total) ? total : null });
124
+ }
125
+ // Throttle console-friendly updates to ~every 2MB
126
+ if (transferred - lastLogged >= 2 * 1024 * 1024 || transferred === total) {
127
+ lastLogged = transferred;
128
+ }
129
+ callback(null, chunk);
130
+ },
131
+ });
132
+ try {
133
+ const nodeReadable = Readable.fromWeb(res.body);
134
+ await pipeline(nodeReadable, progressTap, fileStream);
135
+ }
136
+ catch (error) {
137
+ const detail = error instanceof Error ? error.message : String(error);
138
+ throw new ApiError(`Download interrupted after ${formatBytes(transferred)}${total ? ` of ${formatBytes(total)}` : ""}: ${detail}`);
139
+ }
140
+ return { bytes: transferred, total: Number.isFinite(total) ? total : null };
101
141
  }
142
+ export { formatBytes };
package/dist/index.js CHANGED
@@ -23,11 +23,32 @@ function parseTools(value) {
23
23
  .map((t) => t.trim().toLowerCase())
24
24
  .filter(Boolean);
25
25
  }
26
+ let verbose = false;
27
+ function printError(error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ console.error(`\nError: ${message}`);
30
+ if (verbose && error instanceof Error && error.stack) {
31
+ console.error(error.stack);
32
+ }
33
+ console.error("");
34
+ }
35
+ process.on("unhandledRejection", (reason) => {
36
+ printError(reason);
37
+ process.exit(1);
38
+ });
39
+ process.on("uncaughtException", (error) => {
40
+ printError(error);
41
+ process.exit(1);
42
+ });
26
43
  const program = new Command();
27
44
  program
28
45
  .name("nucleo-skills")
29
46
  .description("Install and update Nucleo Pro MCP + icon skills")
30
- .version(packageVersion());
47
+ .version(packageVersion())
48
+ .option("-v, --verbose", "Show full error details")
49
+ .hook("preAction", (thisCommand) => {
50
+ verbose = Boolean(thisCommand.opts().verbose);
51
+ });
31
52
  program
32
53
  .command("setup")
33
54
  .description("Sign in, download your licensed package, configure your editor")
@@ -52,7 +73,7 @@ program
52
73
  });
53
74
  }
54
75
  catch (error) {
55
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
76
+ printError(error);
56
77
  process.exitCode = 1;
57
78
  }
58
79
  });
@@ -73,7 +94,7 @@ program
73
94
  });
74
95
  }
75
96
  catch (error) {
76
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
97
+ printError(error);
77
98
  process.exitCode = 1;
78
99
  }
79
100
  });
@@ -85,7 +106,7 @@ program
85
106
  await runStatus();
86
107
  }
87
108
  catch (error) {
88
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
109
+ printError(error);
89
110
  process.exitCode = 1;
90
111
  }
91
112
  });
@@ -97,7 +118,7 @@ program
97
118
  await runLogout();
98
119
  }
99
120
  catch (error) {
100
- console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ printError(error);
101
122
  process.exitCode = 1;
102
123
  }
103
124
  });
package/dist/install.d.ts CHANGED
@@ -4,6 +4,8 @@ export declare function readInstallManifest(root?: string): Promise<InstallManif
4
4
  export declare function cleanInstallRoot(root?: string): Promise<void>;
5
5
  /**
6
6
  * Download all release files (signed URLs) and merge into the install root.
7
+ * Downloads complete first, then the install folder is replaced — so a failed
8
+ * download does not wipe a previous working install.
7
9
  */
8
10
  export declare function installFromRelease(release: SkillsReleaseData, target: PackageTarget, root?: string): Promise<void>;
9
11
  /**
package/dist/install.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
- import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import extract from "extract-zip";
7
- import { downloadUrlToFile } from "./api.js";
7
+ import { downloadUrlToFile, formatBytes } from "./api.js";
8
8
  import { installRoot, nucleoHomeDir } from "./config.js";
9
9
  const execFileAsync = promisify(execFile);
10
10
  export async function readInstallManifest(root = installRoot()) {
@@ -39,26 +39,65 @@ async function extractZipMerge(zipPath, dest) {
39
39
  }
40
40
  /**
41
41
  * Download all release files (signed URLs) and merge into the install root.
42
+ * Downloads complete first, then the install folder is replaced — so a failed
43
+ * download does not wipe a previous working install.
42
44
  */
43
45
  export async function installFromRelease(release, target, root = installRoot()) {
44
46
  if (!release.downloadReady) {
45
47
  throw new Error(release.message ||
46
- "Downloads are not ready on the server yet (R2 not configured or urls missing).");
48
+ "Downloads are not ready on the server yet. Please try again later.");
47
49
  }
48
50
  const missingUrl = release.files.filter((f) => !f.url);
49
51
  if (missingUrl.length > 0) {
50
52
  throw new Error(`Release files missing download urls: ${missingUrl.map((f) => f.id).join(", ")}`);
51
53
  }
52
- await cleanInstallRoot(root);
53
54
  const downloadsDir = join(nucleoHomeDir(), "downloads");
54
55
  await mkdir(downloadsDir, { recursive: true });
56
+ const downloaded = [];
55
57
  try {
58
+ // 1) Download everything first (keeps existing install intact on failure)
56
59
  for (const file of release.files) {
57
60
  const zipPath = join(downloadsDir, `${file.id}-${release.version}.zip`);
58
- console.log(` ↓ ${file.id}`);
59
- await downloadUrlToFile(file.url, zipPath);
60
- await extractZipMerge(zipPath, root);
61
- await rm(zipPath, { force: true }).catch(() => undefined);
61
+ process.stdout.write(` ↓ ${file.id}`);
62
+ let lastLine = "";
63
+ try {
64
+ const result = await downloadUrlToFile(file.url, zipPath, ({ transferred, total }) => {
65
+ const label = total
66
+ ? ` ↓ ${file.id} ${formatBytes(transferred)} / ${formatBytes(total)}`
67
+ : ` ↓ ${file.id} ${formatBytes(transferred)}`;
68
+ // overwrite current line
69
+ process.stdout.write(`\r${label}${" ".repeat(Math.max(0, lastLine.length - label.length))}`);
70
+ lastLine = label;
71
+ });
72
+ const sizeLabel = result.total
73
+ ? `${formatBytes(result.bytes)} / ${formatBytes(result.total)}`
74
+ : formatBytes(result.bytes);
75
+ process.stdout.write(`\r ↓ ${file.id} ${sizeLabel} ✓\n`);
76
+ }
77
+ catch (error) {
78
+ process.stdout.write("\n");
79
+ throw error;
80
+ }
81
+ const info = await stat(zipPath);
82
+ if (info.size <= 0) {
83
+ throw new Error(`Downloaded file is empty: ${file.id}`);
84
+ }
85
+ downloaded.push({ id: file.id, zipPath });
86
+ }
87
+ // 2) Only now replace the install root
88
+ console.log(" Extracting…");
89
+ await cleanInstallRoot(root);
90
+ for (const file of downloaded) {
91
+ console.log(` ▸ ${file.id}`);
92
+ try {
93
+ await extractZipMerge(file.zipPath, root);
94
+ }
95
+ catch (error) {
96
+ const detail = error instanceof Error ? error.message : String(error);
97
+ throw new Error(`Failed extracting ${file.id} (${formatBytes((await stat(file.zipPath)).size)}). ${detail}\n` +
98
+ `If this keeps happening, free disk space or try again with:\n` +
99
+ ` NODE_OPTIONS=--max-old-space-size=8192 npx @nucleoicons/skills setup …`);
100
+ }
62
101
  }
63
102
  }
64
103
  finally {
@@ -116,11 +155,19 @@ export async function buildMcp(root = installRoot()) {
116
155
  }
117
156
  const distEntry = join(mcpDir, "dist", "index.js");
118
157
  const hasDist = existsSync(distEntry);
119
- await execFileAsync("npm", ["install", "--omit=dev"], {
120
- cwd: mcpDir,
121
- env: process.env,
122
- });
158
+ console.log(" npm install (mcp)…");
159
+ try {
160
+ await execFileAsync("npm", ["install", "--omit=dev"], {
161
+ cwd: mcpDir,
162
+ env: process.env,
163
+ });
164
+ }
165
+ catch (error) {
166
+ const detail = error instanceof Error ? error.message : String(error);
167
+ throw new Error(`MCP dependency install failed: ${detail}`);
168
+ }
123
169
  if (!hasDist || existsSync(join(mcpDir, "src"))) {
170
+ console.log(" npm run build (mcp)…");
124
171
  try {
125
172
  await execFileAsync("npm", ["run", "build"], {
126
173
  cwd: mcpDir,
@@ -129,7 +176,8 @@ export async function buildMcp(root = installRoot()) {
129
176
  }
130
177
  catch (error) {
131
178
  if (!existsSync(distEntry)) {
132
- throw error;
179
+ const detail = error instanceof Error ? error.message : String(error);
180
+ throw new Error(`MCP build failed: ${detail}`);
133
181
  }
134
182
  }
135
183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nucleoicons/skills",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "CLI to install and update Nucleo Pro MCP + icon skills",
5
5
  "type": "module",
6
6
  "bin": {