@drops-it/dpm 1.0.6 → 1.1.5

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.
@@ -0,0 +1,39 @@
1
+ import type { DpmConfig } from "./config.js";
2
+ /** Pinned managed runtime version (oven-sh/bun release tag without prefix). */
3
+ export declare const RUNTIME_VERSION = "1.2.19";
4
+ export declare function managedRuntimePath(): string;
5
+ /**
6
+ * Ensures the managed DPM runtime binary exists at a stable path.
7
+ * Downloads a pinned release from GitHub when missing or version-mismatched.
8
+ */
9
+ export declare function ensureRuntime(): Promise<string>;
10
+ /**
11
+ * Resolves a runnable runtime path: managed binary, else PATH fallback branded as DPM.
12
+ */
13
+ export declare function resolveRuntime(): Promise<string>;
14
+ export type SpawnRuntimeOptions = {
15
+ cwd?: string;
16
+ env?: NodeJS.ProcessEnv;
17
+ /** Extra args after the runtime binary (e.g. ["install"], ["run", "start"]). */
18
+ args: string[];
19
+ /** When true, pipe + rewrite stdout/stderr so bun never leaks into the UI. */
20
+ brandOutput?: boolean;
21
+ };
22
+ /**
23
+ * Spawns the managed runtime with inherited stdio and returns the exit code.
24
+ * With `brandOutput`, both stdout and stderr are piped, branded, and each
25
+ * write starts on a clean line so it never glues onto a DPM spinner.
26
+ */
27
+ export declare function spawnRuntime(opts: SpawnRuntimeOptions): Promise<number>;
28
+ /**
29
+ * Resolves the install registry URL.
30
+ * DPM hosts use the `/npm` facade; bootstrap / this app monorepo / npmjs → public npm.
31
+ */
32
+ export declare function resolveInstallRegistryUrl(registry: string, cwd?: string): string;
33
+ /**
34
+ * Writes project-local registry config so the runtime installer hits the DPM npm facade
35
+ * (or public npm when bootstrapping / installing this app monorepo).
36
+ */
37
+ export declare function ensureInstallConfig(config: DpmConfig, cwd?: string): Promise<void>;
38
+ /** Builds npm_config_* env for a one-shot install without relying only on files. */
39
+ export declare function runtimeInstallEnv(config: DpmConfig, cwd?: string): NodeJS.ProcessEnv;
@@ -0,0 +1,279 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
+ import { access, chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { homedir, platform, tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ /** Public npm — used when this app must install while its own registry is offline. */
7
+ const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org/";
8
+ /** Pinned managed runtime version (oven-sh/bun release tag without prefix). */
9
+ export const RUNTIME_VERSION = "1.2.19";
10
+ const RUNTIME_NAME = process.platform === "win32" ? "dpm-runtime.exe" : "dpm-runtime";
11
+ function runtimeRoot() {
12
+ if (process.env.DPM_RUNTIME_DIR)
13
+ return process.env.DPM_RUNTIME_DIR;
14
+ if (process.platform === "win32") {
15
+ const base = process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local");
16
+ return path.join(base, "Programs", "DPM", "runtime");
17
+ }
18
+ return path.join(homedir(), ".dpm", "runtime");
19
+ }
20
+ export function managedRuntimePath() {
21
+ return path.join(runtimeRoot(), RUNTIME_NAME);
22
+ }
23
+ async function exists(p) {
24
+ try {
25
+ await access(p);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function assetForPlatform() {
33
+ const os = platform();
34
+ const arch = process.arch;
35
+ if (os === "win32") {
36
+ return {
37
+ asset: arch === "arm64" ? "bun-windows-x64.zip" : "bun-windows-x64.zip",
38
+ isZip: true,
39
+ };
40
+ }
41
+ if (os === "darwin") {
42
+ return {
43
+ asset: arch === "arm64" ? "bun-darwin-aarch64.zip" : "bun-darwin-x64.zip",
44
+ isZip: true,
45
+ };
46
+ }
47
+ // linux
48
+ if (arch === "arm64") {
49
+ return { asset: "bun-linux-aarch64.zip", isZip: true };
50
+ }
51
+ return { asset: "bun-linux-x64.zip", isZip: true };
52
+ }
53
+ function releaseUrl(asset) {
54
+ return `https://github.com/oven-sh/bun/releases/download/bun-v${RUNTIME_VERSION}/${asset}`;
55
+ }
56
+ async function downloadFile(url, dest) {
57
+ const res = await fetch(url, {
58
+ headers: { "user-agent": "dpm-runtime-installer" },
59
+ redirect: "follow",
60
+ });
61
+ if (!res.ok) {
62
+ throw new Error(`DPM runtime download failed (${res.status}).`);
63
+ }
64
+ await mkdir(path.dirname(dest), { recursive: true });
65
+ await writeFile(dest, Buffer.from(await res.arrayBuffer()));
66
+ }
67
+ async function extractZip(zipPath, destDir) {
68
+ await mkdir(destDir, { recursive: true });
69
+ if (process.platform === "win32") {
70
+ const ps = `
71
+ $ErrorActionPreference = 'Stop'
72
+ Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force
73
+ `;
74
+ execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true, stdio: "ignore" });
75
+ }
76
+ else {
77
+ execFileSync("unzip", ["-o", zipPath, "-d", destDir], { stdio: "ignore" });
78
+ }
79
+ const walk = (dir) => {
80
+ for (const name of readdirSync(dir)) {
81
+ const full = path.join(dir, name);
82
+ const st = statSync(full);
83
+ if (st.isDirectory()) {
84
+ const found = walk(full);
85
+ if (found)
86
+ return found;
87
+ }
88
+ else if (/^bun(\.exe)?$/i.test(name)) {
89
+ return full;
90
+ }
91
+ }
92
+ return null;
93
+ };
94
+ const binary = walk(destDir);
95
+ if (!binary)
96
+ throw new Error("DPM runtime archive did not contain a binary.");
97
+ return binary;
98
+ }
99
+ /**
100
+ * Ensures the managed DPM runtime binary exists at a stable path.
101
+ * Downloads a pinned release from GitHub when missing or version-mismatched.
102
+ */
103
+ export async function ensureRuntime() {
104
+ if (process.env.DPM_RUNTIME && (await exists(process.env.DPM_RUNTIME))) {
105
+ return process.env.DPM_RUNTIME;
106
+ }
107
+ const target = managedRuntimePath();
108
+ const versionFile = path.join(runtimeRoot(), "version");
109
+ if (await exists(target) && (await exists(versionFile))) {
110
+ const v = (await readFile(versionFile, "utf8")).trim();
111
+ if (v === RUNTIME_VERSION)
112
+ return target;
113
+ }
114
+ const { asset } = assetForPlatform();
115
+ const url = releaseUrl(asset);
116
+ const tmpDir = path.join(tmpdir(), `dpm-runtime-${Date.now()}`);
117
+ await mkdir(tmpDir, { recursive: true });
118
+ const zipPath = path.join(tmpDir, asset);
119
+ try {
120
+ process.stderr.write("Installing DPM runtime…\n");
121
+ await downloadFile(url, zipPath);
122
+ const extracted = await extractZip(zipPath, path.join(tmpDir, "out"));
123
+ await mkdir(runtimeRoot(), { recursive: true });
124
+ const tmpTarget = `${target}.tmp`;
125
+ await rm(tmpTarget, { force: true });
126
+ // copy via read/write for cross-device moves
127
+ const data = await readFile(extracted);
128
+ await writeFile(tmpTarget, data);
129
+ if (process.platform !== "win32")
130
+ await chmod(tmpTarget, 0o755);
131
+ await rm(target, { force: true });
132
+ await rename(tmpTarget, target);
133
+ await writeFile(versionFile, `${RUNTIME_VERSION}\n`);
134
+ return target;
135
+ }
136
+ finally {
137
+ await rm(tmpDir, { recursive: true, force: true });
138
+ }
139
+ }
140
+ /**
141
+ * Resolves a runnable runtime path: managed binary, else PATH fallback branded as DPM.
142
+ */
143
+ export async function resolveRuntime() {
144
+ try {
145
+ return await ensureRuntime();
146
+ }
147
+ catch (err) {
148
+ const fallback = process.platform === "win32" ? "bun.exe" : "bun";
149
+ const which = spawnSync(process.platform === "win32" ? "where" : "which", [fallback.replace(/\.exe$/, "")], {
150
+ encoding: "utf8",
151
+ windowsHide: true,
152
+ });
153
+ if (which.status === 0 && which.stdout?.trim()) {
154
+ return which.stdout.trim().split(/\r?\n/)[0];
155
+ }
156
+ const msg = err instanceof Error ? err.message : String(err);
157
+ throw new Error(`DPM runtime is not available (${msg}). Re-run the DPM installer or set DPM_RUNTIME.`);
158
+ }
159
+ }
160
+ /** Rewrites managed-runtime banners so users only see DPM branding. */
161
+ function brandText(text) {
162
+ return text
163
+ .replace(/\bBun\b/g, "DPM")
164
+ .replace(/\bbun\b/g, "dpm");
165
+ }
166
+ /**
167
+ * Spawns the managed runtime with inherited stdio and returns the exit code.
168
+ * With `brandOutput`, both stdout and stderr are piped, branded, and each
169
+ * write starts on a clean line so it never glues onto a DPM spinner.
170
+ */
171
+ export async function spawnRuntime(opts) {
172
+ const bin = await resolveRuntime();
173
+ const env = {
174
+ ...process.env,
175
+ ...opts.env,
176
+ // Keep npm-compatible clients pointed at DPM when nested tools spawn.
177
+ npm_config_registry: opts.env?.npm_config_registry ?? process.env.npm_config_registry,
178
+ };
179
+ return new Promise((resolve, reject) => {
180
+ const brand = Boolean(opts.brandOutput);
181
+ const child = spawn(bin, opts.args, {
182
+ cwd: opts.cwd ?? process.cwd(),
183
+ env,
184
+ stdio: brand ? ["inherit", "pipe", "pipe"] : "inherit",
185
+ windowsHide: true,
186
+ });
187
+ let needClear = brand;
188
+ const writeBranded = (stream, chunk) => {
189
+ let text = brandText(chunk.toString("utf8"));
190
+ if (!text)
191
+ return;
192
+ if (needClear) {
193
+ // Drop any leftover `\r`-style status line before the first child byte.
194
+ stream.write("\r\x1b[2K");
195
+ needClear = false;
196
+ }
197
+ // If a chunk starts mid-line after a previous incomplete write, keep as-is;
198
+ // only force a newline when the previous paint left the cursor mid-status.
199
+ stream.write(text);
200
+ };
201
+ if (brand && child.stdout) {
202
+ child.stdout.on("data", (chunk) => {
203
+ writeBranded(process.stdout, chunk);
204
+ });
205
+ }
206
+ if (brand && child.stderr) {
207
+ child.stderr.on("data", (chunk) => {
208
+ writeBranded(process.stderr, chunk);
209
+ });
210
+ }
211
+ child.on("error", reject);
212
+ child.on("close", (code) => resolve(code ?? 1));
213
+ });
214
+ }
215
+ /**
216
+ * True for the DPM registry app monorepo (`package.json` name dpm + private).
217
+ * That tree must install from public npm — its own /npm façade is down during restart.
218
+ */
219
+ function isDpmAppMonorepo(cwd) {
220
+ try {
221
+ const pkg = JSON.parse(readFileSync(path.join(cwd, "package.json"), "utf8"));
222
+ return pkg.name === "dpm" && pkg.private === true;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
228
+ /**
229
+ * Resolves the install registry URL.
230
+ * DPM hosts use the `/npm` facade; bootstrap / this app monorepo / npmjs → public npm.
231
+ */
232
+ export function resolveInstallRegistryUrl(registry, cwd = process.cwd()) {
233
+ if (isDpmAppMonorepo(cwd))
234
+ return PUBLIC_NPM_REGISTRY;
235
+ const base = registry.replace(/\/$/, "").replace(/\/npm$/i, "");
236
+ const usePublicNpm = process.env.DPM_BOOTSTRAP === "1" ||
237
+ process.env.DPM_USE_NPM_UPSTREAM === "1" ||
238
+ /registry\.npmjs\.org/i.test(base);
239
+ if (usePublicNpm)
240
+ return PUBLIC_NPM_REGISTRY;
241
+ return `${base}/npm/`;
242
+ }
243
+ /**
244
+ * Writes project-local registry config so the runtime installer hits the DPM npm facade
245
+ * (or public npm when bootstrapping / installing this app monorepo).
246
+ */
247
+ export async function ensureInstallConfig(config, cwd = process.cwd()) {
248
+ const registryUrl = resolveInstallRegistryUrl(config.registry, cwd);
249
+ const host = new URL(registryUrl).host;
250
+ const pathPrefix = registryUrl.includes("/npm") ? "/npm" : "";
251
+ const token = registryUrl.includes("registry.npmjs.org")
252
+ ? ""
253
+ : (config.token ?? "");
254
+ const npmrcLines = [
255
+ `registry=${registryUrl}`,
256
+ `//${host}${pathPrefix}/:_authToken=${token}`,
257
+ ];
258
+ await writeFile(path.join(cwd, ".npmrc"), `${npmrcLines.join("\n")}\n`, {
259
+ mode: 0o600,
260
+ });
261
+ const bunfig = `[install]
262
+ registry = "${registryUrl}"
263
+ `;
264
+ await writeFile(path.join(cwd, "bunfig.toml"), bunfig);
265
+ }
266
+ /** Builds npm_config_* env for a one-shot install without relying only on files. */
267
+ export function runtimeInstallEnv(config, cwd = process.cwd()) {
268
+ const registryUrl = resolveInstallRegistryUrl(config.registry, cwd);
269
+ const useToken = !registryUrl.includes("registry.npmjs.org") && config.token;
270
+ return {
271
+ npm_config_registry: registryUrl,
272
+ ...(useToken
273
+ ? {
274
+ npm_config__authToken: config.token,
275
+ BUN_CONFIG_TOKEN: config.token,
276
+ }
277
+ : {}),
278
+ };
279
+ }
@@ -1,7 +1,7 @@
1
- export declare const CLI_NAME: string;
1
+ export declare const CLI_NAME = "@drops-it/dpm";
2
2
  export declare const CLI_VERSION: string;
3
3
  /**
4
4
  * On the first dpm command in a new terminal session, checks the DPM registry
5
- * for a newer CLI and prints how to update with dpm. Never blocks on failure.
5
+ * for a newer CLI and prints a one-line hint (non-blocking on failure).
6
6
  */
7
7
  export declare function maybeNotifyUpdate(): Promise<void>;
@@ -1,17 +1,55 @@
1
1
  import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { readFileSync } from "node:fs";
2
3
  import { createRequire } from "node:module";
3
4
  import { homedir } from "node:os";
4
5
  import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
5
7
  import semver from "semver";
6
8
  import { c } from "./colors.js";
7
9
  import { loadConfig } from "./config.js";
8
- const require = createRequire(import.meta.url);
9
- const pkg = require("../package.json");
10
- export const CLI_NAME = pkg.name;
11
- export const CLI_VERSION = pkg.version;
10
+ /**
11
+ * Resolves CLI version from esbuild inject, nearby package.json (npm layout),
12
+ * or a safe fallback — never throws at import time.
13
+ */
14
+ function resolveCliVersion() {
15
+ // Windows single-file bundle: esbuild define replaces this identifier.
16
+ try {
17
+ const injected = new Function("return typeof __DPM_CLI_VERSION__ !== 'undefined' ? __DPM_CLI_VERSION__ : undefined")();
18
+ if (typeof injected === "string" && injected.length > 0)
19
+ return injected;
20
+ }
21
+ catch {
22
+ /* not injected (tsc / npm dist) */
23
+ }
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ for (const file of [
26
+ path.join(here, "..", "package.json"),
27
+ path.join(here, "package.json"),
28
+ ]) {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
31
+ if (pkg.version)
32
+ return pkg.version;
33
+ }
34
+ catch {
35
+ /* try next */
36
+ }
37
+ }
38
+ try {
39
+ const req = createRequire(import.meta.url);
40
+ const pkg = req("../package.json");
41
+ if (pkg.version)
42
+ return pkg.version;
43
+ }
44
+ catch {
45
+ /* bundled exe has no sibling package.json */
46
+ }
47
+ return "1.1.5";
48
+ }
49
+ export const CLI_NAME = "@drops-it/dpm";
50
+ export const CLI_VERSION = resolveCliVersion();
12
51
  const STATE_DIR = path.join(homedir(), ".dpm");
13
52
  const STATE_PATH = path.join(STATE_DIR, "update-check.json");
14
- /** Identifies the current terminal so we only notify once per new shell. */
15
53
  function sessionKey() {
16
54
  const parts = [`ppid:${process.ppid}`];
17
55
  if (process.env.WT_SESSION)
@@ -22,11 +60,12 @@ function sessionKey() {
22
60
  }
23
61
  async function loadState() {
24
62
  try {
25
- const raw = JSON.parse(await readFile(STATE_PATH, "utf8"));
26
- if (Array.isArray(raw.sessions)) {
27
- return { sessions: raw.sessions.slice(-40), latest: raw.latest };
28
- }
29
- return { sessions: [], latest: raw.latest };
63
+ const raw = await readFile(STATE_PATH, "utf8");
64
+ const parsed = JSON.parse(raw);
65
+ return {
66
+ sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
67
+ latest: parsed.latest,
68
+ };
30
69
  }
31
70
  catch {
32
71
  return { sessions: [] };
@@ -34,48 +73,44 @@ async function loadState() {
34
73
  }
35
74
  async function saveState(state) {
36
75
  await mkdir(STATE_DIR, { recursive: true });
37
- await writeFile(STATE_PATH, JSON.stringify({ sessions: state.sessions.slice(-40), latest: state.latest }), { mode: 0o600 });
76
+ await writeFile(STATE_PATH, JSON.stringify(state), { mode: 0o600 });
38
77
  }
39
78
  async function fetchLatestFromDpm(registry) {
40
79
  const url = `${registry.replace(/\/$/, "")}/dpm/v1/packages/dpm`;
41
80
  const res = await fetch(url, {
42
- headers: { accept: "application/json", "user-agent": `dpm-cli/${CLI_VERSION}` },
43
- signal: AbortSignal.timeout(2500),
81
+ headers: {
82
+ accept: "application/json",
83
+ "user-agent": `dpm-cli/${CLI_VERSION}`,
84
+ },
44
85
  });
45
86
  if (!res.ok)
46
87
  return null;
47
- const data = (await res.json());
48
- return data.latest || data["dist-tags"]?.latest || null;
88
+ const body = (await res.json());
89
+ return body.latest ?? body["dist-tags"]?.latest ?? null;
49
90
  }
50
91
  /**
51
92
  * On the first dpm command in a new terminal session, checks the DPM registry
52
- * for a newer CLI and prints how to update with dpm. Never blocks on failure.
93
+ * for a newer CLI and prints a one-line hint (non-blocking on failure).
53
94
  */
54
95
  export async function maybeNotifyUpdate() {
55
- const session = sessionKey();
56
- const prev = await loadState();
57
- if (prev.sessions.includes(session))
58
- return;
59
- await saveState({ sessions: [...prev.sessions, session], latest: prev.latest });
60
96
  try {
97
+ const key = sessionKey();
98
+ const state = await loadState();
99
+ if (state.sessions.includes(key))
100
+ return;
61
101
  const config = await loadConfig();
62
102
  const latest = await fetchLatestFromDpm(config.registry);
103
+ state.sessions = [...state.sessions.filter((s) => s !== key), key].slice(-20);
104
+ if (latest)
105
+ state.latest = latest;
106
+ await saveState(state);
63
107
  if (!latest || !semver.valid(latest) || !semver.valid(CLI_VERSION))
64
108
  return;
65
- await saveState({
66
- sessions: [...prev.sessions, session],
67
- latest,
68
- });
69
109
  if (!semver.gt(latest, CLI_VERSION))
70
110
  return;
71
- console.error([
72
- "",
73
- `${c.yellow("▲")} ${c.bold("dpm")} update available: ${c.dim(CLI_VERSION)} ${c.cyan("→")} ${c.green(latest)}`,
74
- ` Update with: ${c.bold("dpm i -g dpm")}`,
75
- "",
76
- ].join("\n"));
111
+ console.error(`${c.yellow("▲")} ${c.bold("dpm")} update available: ${c.dim(CLI_VERSION)} ${c.cyan("→")} ${c.green(latest)}`);
77
112
  }
78
113
  catch {
79
- // Ignore network / parse errors — update checks must never break commands.
114
+ /* never block the command */
80
115
  }
81
116
  }
@@ -0,0 +1,16 @@
1
+ import type { Ui } from "./ui.js";
2
+ /** `%LOCALAPPDATA%\Programs\DPM` — where `dpm.exe` setup installs. */
3
+ export declare function windowsExeInstallDir(): string;
4
+ export declare function windowsExePath(): string;
5
+ /** True when the Windows launcher install is present. */
6
+ export declare function hasWindowsExeInstall(): Promise<boolean>;
7
+ /**
8
+ * Uninstalls the Windows `dpm.exe` layout (PATH, shortcut, cache, install dir).
9
+ * Schedules delete of the running tree after this process exits.
10
+ */
11
+ export declare function uninstallWindowsExe(ui: Ui): Promise<void>;
12
+ /**
13
+ * Replaces `%LOCALAPPDATA%\\Programs\\DPM\\dpm.exe` from the registry site's
14
+ * `/download/dpm.exe` (see latest.json). Used for `dpm i -g dpm` on Windows exe installs.
15
+ */
16
+ export declare function updateWindowsExe(ui: Ui): Promise<void>;
@@ -0,0 +1,175 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { createWriteStream } from "node:fs";
3
+ import { access, mkdir, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import path from "node:path";
6
+ import { pipeline } from "node:stream/promises";
7
+ import { Readable } from "node:stream";
8
+ import { loadConfig } from "./config.js";
9
+ import { CLI_VERSION } from "./updateCheck.js";
10
+ async function exists(p) {
11
+ try {
12
+ await access(p);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** `%LOCALAPPDATA%\Programs\DPM` — where `dpm.exe` setup installs. */
20
+ export function windowsExeInstallDir() {
21
+ const base = process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local");
22
+ return path.join(base, "Programs", "DPM");
23
+ }
24
+ export function windowsExePath() {
25
+ return path.join(windowsExeInstallDir(), "dpm.exe");
26
+ }
27
+ /** True when the Windows launcher install is present. */
28
+ export async function hasWindowsExeInstall() {
29
+ return process.platform === "win32" && exists(windowsExePath());
30
+ }
31
+ function startMenuShortcutPath() {
32
+ const appData = process.env.APPDATA || path.join(homedir(), "AppData", "Roaming");
33
+ return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "DPM.lnk");
34
+ }
35
+ function cliCacheRoot() {
36
+ const base = process.env.LOCALAPPDATA || path.join(homedir(), "AppData", "Local");
37
+ return path.join(base, "dpm", "cli");
38
+ }
39
+ /** Removes `dir` from the user PATH (HKCU Environment). */
40
+ function removeFromUserPath(dir) {
41
+ const ps = `
42
+ $dir = [IO.Path]::GetFullPath('${dir.replace(/'/g, "''")}')
43
+ $key = 'HKCU:\\Environment'
44
+ $cur = (Get-ItemProperty -Path $key -Name Path -ErrorAction SilentlyContinue).Path
45
+ if (-not $cur) { return }
46
+ $parts = $cur -split ';' | Where-Object {
47
+ $_.Trim() -ne '' -and [IO.Path]::GetFullPath($_.Trim()) -ne $dir
48
+ }
49
+ $next = ($parts -join ';').Trim(';')
50
+ Set-ItemProperty -Path $key -Name Path -Value $next
51
+ Add-Type -Namespace Win32 -Name Native -MemberDefinition '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'
52
+ $hwnd = [IntPtr]0xffff; $r = [UIntPtr]::Zero
53
+ [Win32.Native]::SendMessageTimeout($hwnd, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$r) | Out-Null
54
+ `;
55
+ spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true, stdio: "ignore" });
56
+ }
57
+ /**
58
+ * Uninstalls the Windows `dpm.exe` layout (PATH, shortcut, cache, install dir).
59
+ * Schedules delete of the running tree after this process exits.
60
+ */
61
+ export async function uninstallWindowsExe(ui) {
62
+ const installDir = windowsExeInstallDir();
63
+ const exe = windowsExePath();
64
+ if (!(await exists(exe))) {
65
+ ui.warn("No Windows dpm.exe install found under Local\\Programs\\DPM");
66
+ return;
67
+ }
68
+ ui.info("Removing Windows DPM install…");
69
+ removeFromUserPath(installDir);
70
+ await rm(startMenuShortcutPath(), { force: true });
71
+ await rm(cliCacheRoot(), { recursive: true, force: true });
72
+ // Cannot delete the running exe reliably — cmd deletes the folder after exit.
73
+ const bat = path.join(process.env.TEMP || installDir, `dpm-uninstall-${Date.now()}.cmd`);
74
+ const batBody = [
75
+ "@echo off",
76
+ "timeout /t 2 /nobreak >nul",
77
+ `rmdir /s /q "${installDir}"`,
78
+ `del /f /q "%~f0"`,
79
+ "",
80
+ ].join("\r\n");
81
+ await writeFile(bat, batBody, "utf8");
82
+ const child = spawn("cmd.exe", ["/c", "start", "/b", "", bat], {
83
+ windowsHide: true,
84
+ stdio: "ignore",
85
+ detached: true,
86
+ });
87
+ child.unref();
88
+ ui.success("Uninstalled DPM from PATH and Start Menu. Install folder will be removed shortly — open a new terminal.");
89
+ }
90
+ function siteBaseFromRegistry(registry) {
91
+ return registry.replace(/\/$/, "").replace(/\/npm$/i, "");
92
+ }
93
+ async function fetchLatestMeta(site) {
94
+ const url = `${site}/download/latest.json`;
95
+ const res = await fetch(url, {
96
+ headers: { accept: "application/json", "user-agent": `dpm-cli/${CLI_VERSION}` },
97
+ });
98
+ if (!res.ok) {
99
+ throw new Error(`Could not fetch ${url} (${res.status})`);
100
+ }
101
+ return (await res.json());
102
+ }
103
+ async function downloadToFile(url, dest) {
104
+ const res = await fetch(url, {
105
+ headers: { "user-agent": `dpm-cli/${CLI_VERSION}` },
106
+ redirect: "follow",
107
+ });
108
+ if (!res.ok || !res.body) {
109
+ throw new Error(`Download failed (${res.status}): ${url}`);
110
+ }
111
+ await mkdir(path.dirname(dest), { recursive: true });
112
+ const tmp = `${dest}.download`;
113
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
114
+ await rename(tmp, dest);
115
+ }
116
+ /**
117
+ * Replaces `%LOCALAPPDATA%\\Programs\\DPM\\dpm.exe` from the registry site's
118
+ * `/download/dpm.exe` (see latest.json). Used for `dpm i -g dpm` on Windows exe installs.
119
+ */
120
+ export async function updateWindowsExe(ui) {
121
+ const installDir = windowsExeInstallDir();
122
+ const exe = windowsExePath();
123
+ if (!(await exists(exe))) {
124
+ throw new Error("No Windows dpm.exe install found. Run the downloaded dpm.exe installer first.");
125
+ }
126
+ const config = await loadConfig();
127
+ const site = siteBaseFromRegistry(config.registry);
128
+ ui.info(`Checking ${site}/download/latest.json…`);
129
+ const meta = await fetchLatestMeta(site);
130
+ const remoteVer = meta.version || "unknown";
131
+ if (remoteVer === CLI_VERSION) {
132
+ ui.success(`Already up to date (${CLI_VERSION})`);
133
+ return;
134
+ }
135
+ const downloadPath = meta.downloadPath || "/download/dpm.exe";
136
+ const url = `${site}${downloadPath.startsWith("/") ? "" : "/"}${downloadPath}`;
137
+ const staging = path.join(installDir, "dpm.exe.new");
138
+ ui.info(`Downloading DPM ${remoteVer}…`);
139
+ await downloadToFile(url, staging);
140
+ const backup = path.join(installDir, "dpm.exe.old");
141
+ await rm(backup, { force: true });
142
+ try {
143
+ await rename(exe, backup);
144
+ }
145
+ catch {
146
+ // Running from another copy — overwrite in place via cmd after exit
147
+ }
148
+ try {
149
+ await rename(staging, exe);
150
+ }
151
+ catch {
152
+ // Locked: schedule replace
153
+ const bat = path.join(process.env.TEMP || installDir, `dpm-update-${Date.now()}.cmd`);
154
+ await writeFile(bat, [
155
+ "@echo off",
156
+ "timeout /t 2 /nobreak >nul",
157
+ `move /y "${staging}" "${exe}"`,
158
+ `del /f /q "${backup}" 2>nul`,
159
+ `del /f /q "%~f0"`,
160
+ "",
161
+ ].join("\r\n"), "utf8");
162
+ const child = spawn("cmd.exe", ["/c", "start", "/b", "", bat], {
163
+ windowsHide: true,
164
+ stdio: "ignore",
165
+ detached: true,
166
+ });
167
+ child.unref();
168
+ ui.success(`DPM ${remoteVer} downloaded — restart the terminal (or wait a second) to use it.`);
169
+ return;
170
+ }
171
+ await rm(backup, { force: true });
172
+ // Drop old embedded CLI cache so next launch unpacks the new bundle.
173
+ await rm(cliCacheRoot(), { recursive: true, force: true });
174
+ ui.success(`Updated Windows DPM ${CLI_VERSION} → ${remoteVer}. Open a new terminal if \`dpm -v\` still shows the old version.`);
175
+ }