@lpm-registry/cli 0.3.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.5.0] - 2026-03-25
6
+
7
+ ### Changed
8
+
9
+ - **Rust binary v0.5.0** — Updated binary with install/add separation (Swift via `lpm install`), script runner (`lpm dev`), npmrc command, update notifier, binary lockfile, and more.
10
+ - Version bump to stay in sync with Rust binary releases.
11
+
12
+ ## [0.4.0] - 2026-03-24
13
+
14
+ ### Added
15
+
16
+ - **Rust binary drop-in replacement** — `lpm` now automatically downloads and runs the native Rust binary on postinstall. 9x faster warm installs than pnpm, 24x faster than npm. Falls back to JS CLI if binary is unavailable.
17
+ - **GitHub Releases distribution** — Platform-specific binaries (macOS arm64/x64, Linux arm64/x64, Windows x64) hosted on GitHub Releases, downloaded automatically during `npm install -g @lpm-registry/cli`.
18
+ - **Auto-integrate Agent Skills with AI editors** — After `lpm skills install`, skills are automatically wired into detected AI editor configs (Claude Code `CLAUDE.md`, Cursor `.cursorrules`, etc.). Use `--no-editor-setup` to skip.
19
+ - **`--no-editor-setup` flag** for `lpm install` and `lpm skills install` — Skip auto-configuring AI editor integration for skills.
20
+
21
+ ### Changed
22
+
23
+ - Binary download URL updated from `lpm.dev/releases` to GitHub Releases (`github.com/lpm-dev/rust-client/releases`)
24
+ - Skills install now passes `editorSetup` option through from `lpm install` to `lpm skills install`
25
+ - Fixed unused variable lint warning in `bin/lpm.js`
26
+
5
27
  ## [0.3.0] - 2026-03-22
6
28
 
7
29
  ### Added
package/README.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # LPM CLI
2
2
 
3
- The command-line interface for the Licensed Package Manager (LPM).
3
+ The command-line interface for the Licensed Package Manager (LPM). Ships with a native Rust binary for fast installs — 9x faster than pnpm, 24x faster than npm.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
+ # npm (recommended — auto-downloads native binary)
8
9
  npm install -g @lpm-registry/cli
10
+
11
+ # curl (standalone, no Node required)
12
+ curl -fsSL https://raw.githubusercontent.com/lpm-dev/rust-client/main/install.sh | sh
9
13
  ```
10
14
 
11
15
  ## Command Shortcuts
package/bin/lpm.js CHANGED
@@ -1,8 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { execFileSync } from "node:child_process"
3
4
  import fs from "node:fs"
4
5
  import path from "node:path"
5
6
  import { fileURLToPath } from "node:url"
7
+
8
+ // ─── Try Rust binary first ──────────────────────────────────────────
9
+ const __dirname_early = path.dirname(fileURLToPath(import.meta.url))
10
+ const binaryName = process.platform === "win32" ? "lpm-bin.exe" : "lpm-bin"
11
+ const binaryPath = path.join(__dirname_early, binaryName)
12
+
13
+ if (fs.existsSync(binaryPath)) {
14
+ try {
15
+ // Delegate to Rust binary with all args
16
+ execFileSync(binaryPath, process.argv.slice(2), {
17
+ stdio: "inherit",
18
+ env: process.env,
19
+ })
20
+ process.exit(0)
21
+ } catch (err) {
22
+ // execFileSync throws on non-zero exit — propagate the exit code
23
+ if (err.status != null) {
24
+ process.exit(err.status)
25
+ }
26
+ // Binary failed to execute — fall through to JS CLI
27
+ }
28
+ }
29
+
30
+ // ─── JS CLI fallback ────────────────────────────────────────────────
6
31
  import { Command } from "commander"
7
32
  import updateNotifier from "update-notifier"
8
33
  import { add } from "../lib/commands/add.js"
@@ -94,6 +119,10 @@ program
94
119
  .description("Install packages with automatic registry authentication")
95
120
  .option("--json", "Machine-readable JSON output")
96
121
  .option("--no-skills", "Skip fetching Agent Skills after install")
122
+ .option(
123
+ "--no-editor-setup",
124
+ "Skip auto-configuring AI editor integration for skills",
125
+ )
97
126
  .option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)")
98
127
  .action(install)
99
128
 
@@ -331,6 +360,10 @@ skills
331
360
  "Fetch and install skills from the registry (all deps or specific package)",
332
361
  )
333
362
  .option("--json", "Output in JSON format")
363
+ .option(
364
+ "--no-editor-setup",
365
+ "Skip auto-configuring AI editor integration for skills",
366
+ )
334
367
  .action(skillsInstall)
335
368
 
336
369
  skills
@@ -180,6 +180,7 @@ export async function install(packages, options) {
180
180
  }
181
181
 
182
182
  const fetchSkills = options?.skills !== false
183
+ const editorSetup = options?.editorSetup !== false
183
184
 
184
185
  if (isJson) {
185
186
  let stdout = ""
@@ -203,12 +204,13 @@ export async function install(packages, options) {
203
204
  process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
204
205
  if (code !== 0) process.exit(code)
205
206
  if (fetchSkills) {
207
+ const skillOpts = { json: true, editorSetup }
206
208
  if (explicitPackages) {
207
209
  for (const pkg of packages.filter(isLpmPackage)) {
208
- await skillsInstall(pkg, { json: true })
210
+ await skillsInstall(pkg, skillOpts)
209
211
  }
210
212
  } else {
211
- await skillsInstall(null, { json: true })
213
+ await skillsInstall(null, skillOpts)
212
214
  }
213
215
  }
214
216
  })
@@ -235,12 +237,13 @@ export async function install(packages, options) {
235
237
  log.success("Packages installed successfully.")
236
238
  if (fetchSkills) {
237
239
  console.log("")
240
+ const skillOpts = { editorSetup }
238
241
  if (explicitPackages) {
239
242
  for (const pkg of packages.filter(isLpmPackage)) {
240
- await skillsInstall(pkg)
243
+ await skillsInstall(pkg, skillOpts)
241
244
  }
242
245
  } else {
243
- await skillsInstall(null)
246
+ await skillsInstall(null, skillOpts)
244
247
  }
245
248
  }
246
249
  }
@@ -12,6 +12,7 @@ import fs from "node:fs"
12
12
  import path from "node:path"
13
13
  import chalk from "chalk"
14
14
  import { get } from "../api.js"
15
+ import { autoIntegrateSkills } from "../editor-skills.js"
15
16
  import { createSpinner, log, printHeader } from "../ui.js"
16
17
 
17
18
  // ============================================================================
@@ -616,12 +617,19 @@ export async function skillsInstall(packageInput, options = {}) {
616
617
  if (spinner) spinner.stop()
617
618
 
618
619
  if (isJson) {
620
+ const hasInstalled = results.some(r => r.status === "installed")
621
+ let editorSetup = null
622
+ if (hasInstalled && options.editorSetup !== false) {
623
+ const result = autoIntegrateSkills({ json: true })
624
+ editorSetup = result
625
+ }
619
626
  console.log(
620
627
  JSON.stringify(
621
628
  {
622
629
  installed: results.filter(r => r.status === "installed").length,
623
630
  packages: results,
624
631
  errors,
632
+ ...(editorSetup ? { editorSetup } : {}),
625
633
  },
626
634
  null,
627
635
  2,
@@ -665,7 +673,13 @@ export async function skillsInstall(packageInput, options = {}) {
665
673
  if (installed.length > 0) {
666
674
  console.log("")
667
675
  console.log(chalk.dim(` Skills saved to ${SKILLS_DIR}/`))
668
- printWiringInstructions()
676
+
677
+ // Auto-integrate with detected AI editors (unless --no-editor-setup)
678
+ if (options.editorSetup !== false) {
679
+ autoIntegrateSkills({ json: false })
680
+ } else {
681
+ printWiringInstructions()
682
+ }
669
683
  }
670
684
 
671
685
  console.log("")
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Auto-integrate Agent Skills with AI editor config files.
3
+ *
4
+ * After `lpm skills install` writes skill files to `.lpm/skills/`,
5
+ * this module detects which AI editors are configured in the project
6
+ * and wires the skills in automatically (append reference, symlink, etc.).
7
+ *
8
+ * @module cli/lib/editor-skills
9
+ */
10
+
11
+ import fs from "node:fs"
12
+ import path from "node:path"
13
+ import chalk from "chalk"
14
+ import { log } from "./ui.js"
15
+
16
+ // ============================================================================
17
+ // Constants
18
+ // ============================================================================
19
+
20
+ const SKILLS_DIR = ".lpm/skills"
21
+
22
+ const SKILLS_REFERENCE_LINE =
23
+ "See .lpm/skills/ for package-specific Agent Skills and guidelines."
24
+
25
+ /** Marker comment so we can detect our own additions and avoid duplicates. */
26
+ const LPM_MARKER = "<!-- lpm:skills -->"
27
+
28
+ /**
29
+ * The block we append to markdown/text config files.
30
+ * Contains both a human-readable reference and a machine-detectable marker.
31
+ */
32
+ function makeReferenceBlock() {
33
+ return `\n\n${LPM_MARKER}\n## LPM Agent Skills\n\n${SKILLS_REFERENCE_LINE}\n`
34
+ }
35
+
36
+ // ============================================================================
37
+ // AI Editor definitions (for skills wiring — separate from MCP editors)
38
+ // ============================================================================
39
+
40
+ /**
41
+ * @typedef {object} AIEditor
42
+ * @property {string} id - Unique identifier
43
+ * @property {string} name - Display name
44
+ * @property {string} configPath - Relative path to the config file from project root
45
+ * @property {"append" | "symlink"} action - How to integrate skills
46
+ * @property {(projectRoot: string) => boolean} detect - Whether this editor is configured in the project
47
+ */
48
+
49
+ /** @type {AIEditor[]} */
50
+ const AI_EDITORS = [
51
+ {
52
+ id: "claude-code",
53
+ name: "Claude Code",
54
+ configPath: "CLAUDE.md",
55
+ action: "append",
56
+ detect: projectRoot => fs.existsSync(path.join(projectRoot, "CLAUDE.md")),
57
+ },
58
+ {
59
+ id: "cursor",
60
+ name: "Cursor",
61
+ configPath: ".cursor/rules/",
62
+ action: "symlink",
63
+ detect: projectRoot =>
64
+ fs.existsSync(path.join(projectRoot, ".cursor", "rules")),
65
+ },
66
+ {
67
+ id: "cursor-legacy",
68
+ name: "Cursor (legacy)",
69
+ configPath: ".cursorrules",
70
+ action: "append",
71
+ detect: projectRoot =>
72
+ fs.existsSync(path.join(projectRoot, ".cursorrules")),
73
+ },
74
+ {
75
+ id: "windsurf",
76
+ name: "Windsurf",
77
+ configPath: ".windsurfrules",
78
+ action: "append",
79
+ detect: projectRoot =>
80
+ fs.existsSync(path.join(projectRoot, ".windsurfrules")),
81
+ },
82
+ {
83
+ id: "github-copilot",
84
+ name: "GitHub Copilot",
85
+ configPath: ".github/copilot-instructions.md",
86
+ action: "append",
87
+ detect: projectRoot =>
88
+ fs.existsSync(
89
+ path.join(projectRoot, ".github", "copilot-instructions.md"),
90
+ ),
91
+ },
92
+ {
93
+ id: "augment",
94
+ name: "Augment",
95
+ configPath: ".augment/instructions.md",
96
+ action: "append",
97
+ detect: projectRoot =>
98
+ fs.existsSync(path.join(projectRoot, ".augment", "instructions.md")),
99
+ },
100
+ {
101
+ id: "cline",
102
+ name: "Cline",
103
+ configPath: ".clinerules",
104
+ action: "append",
105
+ detect: projectRoot => fs.existsSync(path.join(projectRoot, ".clinerules")),
106
+ },
107
+ ]
108
+
109
+ // ============================================================================
110
+ // Core logic
111
+ // ============================================================================
112
+
113
+ /**
114
+ * Detect which AI editors are configured in the project.
115
+ *
116
+ * @param {string} projectRoot
117
+ * @returns {AIEditor[]}
118
+ */
119
+ export function detectAIEditors(projectRoot) {
120
+ return AI_EDITORS.filter(editor => editor.detect(projectRoot))
121
+ }
122
+
123
+ /**
124
+ * Check if a file already references .lpm/skills/.
125
+ *
126
+ * @param {string} filePath
127
+ * @returns {boolean}
128
+ */
129
+ export function hasSkillsReference(filePath) {
130
+ if (!fs.existsSync(filePath)) return false
131
+ const content = fs.readFileSync(filePath, "utf-8")
132
+ return content.includes(".lpm/skills") || content.includes(LPM_MARKER)
133
+ }
134
+
135
+ /**
136
+ * Append a skills reference block to a markdown/text config file.
137
+ *
138
+ * @param {string} filePath - Absolute path to the config file
139
+ * @returns {boolean} Whether the reference was added (false if already present)
140
+ */
141
+ export function appendSkillsReference(filePath) {
142
+ if (hasSkillsReference(filePath)) return false
143
+ fs.appendFileSync(filePath, makeReferenceBlock())
144
+ return true
145
+ }
146
+
147
+ /**
148
+ * Create a new CLAUDE.md with skills reference (fallback when no editor detected).
149
+ *
150
+ * @param {string} projectRoot
151
+ * @returns {boolean} Whether the file was created
152
+ */
153
+ export function createClaudeMdWithReference(projectRoot) {
154
+ const claudeMdPath = path.join(projectRoot, "CLAUDE.md")
155
+ if (fs.existsSync(claudeMdPath)) return false
156
+
157
+ const content = `# Project Guidelines\n\n${LPM_MARKER}\n## LPM Agent Skills\n\n${SKILLS_REFERENCE_LINE}\n`
158
+ fs.writeFileSync(claudeMdPath, content, "utf-8")
159
+ return true
160
+ }
161
+
162
+ /**
163
+ * Collect all .md skill files from .lpm/skills/ (recursively through package subdirs).
164
+ *
165
+ * @param {string} projectRoot
166
+ * @returns {{ packageName: string, fileName: string, filePath: string }[]}
167
+ */
168
+ export function collectSkillFiles(projectRoot) {
169
+ const skillsDir = path.join(projectRoot, SKILLS_DIR)
170
+ if (!fs.existsSync(skillsDir)) return []
171
+
172
+ const results = []
173
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
174
+
175
+ for (const entry of entries) {
176
+ if (entry.isDirectory()) {
177
+ // Package subdirectory (e.g., "neo.colors")
178
+ const pkgDir = path.join(skillsDir, entry.name)
179
+ const files = fs.readdirSync(pkgDir).filter(f => f.endsWith(".md"))
180
+ for (const file of files) {
181
+ results.push({
182
+ packageName: entry.name,
183
+ fileName: file,
184
+ filePath: path.join(pkgDir, file),
185
+ })
186
+ }
187
+ } else if (entry.name.endsWith(".md")) {
188
+ // Top-level skill file
189
+ results.push({
190
+ packageName: null,
191
+ fileName: entry.name,
192
+ filePath: path.join(skillsDir, entry.name),
193
+ })
194
+ }
195
+ }
196
+
197
+ return results
198
+ }
199
+
200
+ /**
201
+ * Create symlinks for Cursor's .cursor/rules/ directory.
202
+ * Uses relative symlinks so they work across machines.
203
+ *
204
+ * @param {string} projectRoot
205
+ * @returns {{ created: string[], skipped: string[] }}
206
+ */
207
+ export function symlinkForCursor(projectRoot) {
208
+ const rulesDir = path.join(projectRoot, ".cursor", "rules")
209
+ const skillFiles = collectSkillFiles(projectRoot)
210
+
211
+ const created = []
212
+ const skipped = []
213
+
214
+ for (const { packageName, fileName, filePath } of skillFiles) {
215
+ // Build symlink name: "neo.colors-getting-started.md"
216
+ const linkName = packageName
217
+ ? `${packageName}-${fileName}`
218
+ : `lpm-${fileName}`
219
+
220
+ const linkPath = path.join(rulesDir, linkName)
221
+
222
+ if (fs.existsSync(linkPath)) {
223
+ skipped.push(linkName)
224
+ continue
225
+ }
226
+
227
+ // Compute relative path from .cursor/rules/ to the skill file
228
+ const relativePath = path.relative(rulesDir, filePath)
229
+
230
+ try {
231
+ fs.symlinkSync(relativePath, linkPath)
232
+ created.push(linkName)
233
+ } catch {
234
+ // Fallback: copy if symlink fails (e.g., Windows without developer mode)
235
+ try {
236
+ fs.copyFileSync(filePath, linkPath)
237
+ created.push(linkName)
238
+ } catch {
239
+ skipped.push(linkName)
240
+ }
241
+ }
242
+ }
243
+
244
+ return { created, skipped }
245
+ }
246
+
247
+ // ============================================================================
248
+ // Main entry point
249
+ // ============================================================================
250
+
251
+ /**
252
+ * Auto-integrate installed skills with detected AI editors.
253
+ *
254
+ * @param {object} options
255
+ * @param {string} [options.projectRoot] - Project root (defaults to cwd)
256
+ * @param {boolean} [options.json] - Output JSON instead of human-readable
257
+ * @param {string[]} [options.installedPackages] - Names of packages that had skills installed
258
+ * @returns {{ configured: { id: string, name: string, configPath: string }[], fallback: boolean }}
259
+ */
260
+ export function autoIntegrateSkills(options = {}) {
261
+ const projectRoot = options.projectRoot || process.cwd()
262
+ const isJson = options.json
263
+ const configured = []
264
+ let fallback = false
265
+
266
+ const detectedEditors = detectAIEditors(projectRoot)
267
+
268
+ if (detectedEditors.length === 0) {
269
+ // No editor detected — print a helpful hint, don't auto-create files
270
+ fallback = true
271
+ if (!isJson) {
272
+ console.log("")
273
+ log.info(
274
+ "To use these skills with your AI coding tool, point your agent to the .lpm/skills/ folder.",
275
+ )
276
+ }
277
+ } else {
278
+ for (const editor of detectedEditors) {
279
+ if (editor.action === "symlink" && editor.id === "cursor") {
280
+ const { created } = symlinkForCursor(projectRoot)
281
+ if (created.length > 0) {
282
+ configured.push({
283
+ id: editor.id,
284
+ name: editor.name,
285
+ configPath: editor.configPath,
286
+ action: "symlinked",
287
+ files: created,
288
+ })
289
+ }
290
+ } else if (editor.action === "append") {
291
+ const filePath = path.join(projectRoot, editor.configPath)
292
+ const added = appendSkillsReference(filePath)
293
+ if (added) {
294
+ configured.push({
295
+ id: editor.id,
296
+ name: editor.name,
297
+ configPath: editor.configPath,
298
+ action: "appended",
299
+ })
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // Output
306
+ if (!isJson && configured.length > 0) {
307
+ console.log("")
308
+ console.log(chalk.dim(" Auto-configured for:"))
309
+ for (const c of configured) {
310
+ if (c.action === "symlinked") {
311
+ log.success(
312
+ `${c.name} (${c.configPath}) — ${c.files.length} file${c.files.length !== 1 ? "s" : ""} linked`,
313
+ )
314
+ } else {
315
+ log.success(`${c.name} (${c.configPath})`)
316
+ }
317
+ }
318
+ }
319
+
320
+ return { configured, fallback }
321
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lpm-registry/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for Licensed Package Manager",
5
5
  "author": "LPM <hello@lpm.dev> (https://lpm.dev)",
6
6
  "repository": {
@@ -29,6 +29,7 @@
29
29
  "files": [
30
30
  "bin",
31
31
  "lib",
32
+ "scripts",
32
33
  "!lib/__tests__",
33
34
  "index.js",
34
35
  "index.d.ts",
@@ -38,6 +39,7 @@
38
39
  ],
39
40
  "scripts": {
40
41
  "prepare": "husky",
42
+ "postinstall": "node scripts/install-binary.js",
41
43
  "test": "vitest run",
42
44
  "test:watch": "vitest",
43
45
  "lint": "biome check .",
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Download the platform-specific LPM Rust binary.
5
+ *
6
+ * Runs as a postinstall hook. Downloads the pre-built binary from
7
+ * lpm.dev/releases and places it in the package's bin/ directory.
8
+ *
9
+ * Falls back gracefully to the JS CLI if the binary isn't available
10
+ * for the current platform.
11
+ */
12
+
13
+ import { execSync } from "node:child_process"
14
+ import fs from "node:fs"
15
+ import http from "node:http"
16
+ import https from "node:https"
17
+ import path from "node:path"
18
+ import { fileURLToPath } from "node:url"
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
21
+ const pkgDir = path.join(__dirname, "..")
22
+ const binDir = path.join(pkgDir, "bin")
23
+ const binaryPath = path.join(
24
+ binDir,
25
+ process.platform === "win32" ? "lpm-bin.exe" : "lpm-bin",
26
+ )
27
+
28
+ // Platform → binary filename mapping
29
+ const PLATFORM_MAP = {
30
+ "darwin-arm64": "lpm-darwin-arm64",
31
+ "darwin-x64": "lpm-darwin-x64",
32
+ "linux-x64": "lpm-linux-x64",
33
+ "linux-arm64": "lpm-linux-arm64",
34
+ "win32-x64": "lpm-win32-x64.exe",
35
+ }
36
+
37
+ const GITHUB_RELEASES =
38
+ "https://github.com/lpm-dev/rust-client/releases/download"
39
+ const pkg = JSON.parse(
40
+ fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"),
41
+ )
42
+
43
+ async function main() {
44
+ const platform = `${process.platform}-${process.arch}`
45
+ const binaryName = PLATFORM_MAP[platform]
46
+
47
+ if (!binaryName) {
48
+ console.log(`[lpm] No pre-built binary for ${platform}. Using JS CLI.`)
49
+ return
50
+ }
51
+
52
+ // Skip if binary already exists and is executable
53
+ if (fs.existsSync(binaryPath)) {
54
+ try {
55
+ execSync(`"${binaryPath}" --version`, { stdio: "ignore", timeout: 5000 })
56
+ return
57
+ } catch {
58
+ // Binary exists but broken, re-download
59
+ fs.unlinkSync(binaryPath)
60
+ }
61
+ }
62
+
63
+ const url = `${GITHUB_RELEASES}/v${pkg.version}/${binaryName}`
64
+
65
+ try {
66
+ console.log(`[lpm] Downloading native binary for ${platform}...`)
67
+ await download(url, binaryPath)
68
+ fs.chmodSync(binaryPath, 0o755)
69
+ console.log("[lpm] Native binary installed.")
70
+ } catch (err) {
71
+ console.log(`[lpm] Binary not available: ${err.message}. Using JS CLI.`)
72
+ // Clean up partial download
73
+ if (fs.existsSync(binaryPath)) fs.unlinkSync(binaryPath)
74
+ }
75
+ }
76
+
77
+ function download(url, dest, redirects = 0) {
78
+ if (redirects > 5) return Promise.reject(new Error("Too many redirects"))
79
+
80
+ const client = url.startsWith("https") ? https : http
81
+
82
+ return new Promise((resolve, reject) => {
83
+ client
84
+ .get(url, res => {
85
+ if (
86
+ res.statusCode >= 300 &&
87
+ res.statusCode < 400 &&
88
+ res.headers.location
89
+ ) {
90
+ resolve(download(res.headers.location, dest, redirects + 1))
91
+ return
92
+ }
93
+
94
+ if (res.statusCode !== 200) {
95
+ reject(new Error(`HTTP ${res.statusCode}`))
96
+ return
97
+ }
98
+
99
+ const out = fs.createWriteStream(dest)
100
+ res.pipe(out)
101
+ out.on("finish", () => {
102
+ out.close()
103
+ resolve()
104
+ })
105
+ out.on("error", reject)
106
+ })
107
+ .on("error", reject)
108
+ })
109
+ }
110
+
111
+ main().catch(() => {
112
+ // Silently fail — JS CLI fallback
113
+ })