moondesk 0.1.4 → 0.2.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 CHANGED
@@ -90,6 +90,8 @@ I tried this with GPT-5.2 before, and the results were poor. However, **GPT-5.4
90
90
 
91
91
  MoonDesk does not rely on npm lifecycle/install scripts. On the first `moondesk` run, the small npm wrapper downloads the matching native binary from the GitHub Release, verifies it against that release's `SHA256SUMS`, and stores it in a versioned user cache under `~/.moondesk/npm-bin/`. This works with npm 12's default-deny install-script policy and also keeps the running native executable outside npm's `node_modules`, which makes Windows package upgrades less likely to hit locked-file errors.
92
92
 
93
+ Globally installed npm copies also check `moondesk@latest` in the background. When a newer stable release is available, the Status panel shows the current version and `[u] Update & Restart`. Pressing `u` always opens a confirmation modal first: make sure no ChatGPT/MCP session or command is running because MoonDesk will restart and the active connection will be interrupted. Press `Enter` to continue or `Esc` to abort. The wrapper then installs the exact version that was shown, verifies that npm really replaced the package with that version, restarts MoonDesk in the same working directory with the same arguments, and removes older verified native/update caches on subsequent startup. Multiple MoonDesk processes serialize global npm updates so they do not race each other. Local dependency or `npx` copies never offer in-app self-update.
94
+
93
95
  2. Run MoonDesk from any terminal directory.
94
96
 
95
97
  ```bash
@@ -4,6 +4,7 @@ const crypto = require("node:crypto");
4
4
  const fs = require("node:fs");
5
5
  const os = require("node:os");
6
6
  const path = require("node:path");
7
+ const { compareStableVersions, parseStableVersion } = require("./update-manager");
7
8
 
8
9
  const packageRoot = path.resolve(__dirname, "..");
9
10
  const packageJson = require(path.join(packageRoot, "package.json"));
@@ -44,12 +45,62 @@ function resolveTarget(platform = process.platform, arch = process.arch) {
44
45
  };
45
46
  }
46
47
 
48
+ function defaultBinaryCacheRoot() {
49
+ return path.join(os.homedir(), ".moondesk", "npm-bin");
50
+ }
51
+
47
52
  function defaultInstallDir(target) {
48
53
  if (process.env.MOONDESK_BINARY_CACHE_DIR) {
49
54
  return path.resolve(process.env.MOONDESK_BINARY_CACHE_DIR);
50
55
  }
51
56
 
52
- return path.join(os.homedir(), ".moondesk", "npm-bin", releaseTag, target);
57
+ return path.join(defaultBinaryCacheRoot(), releaseTag, target);
58
+ }
59
+
60
+ function stableTagIsOlder(candidate, current) {
61
+ if (!candidate.startsWith("v") || !current.startsWith("v")) return false;
62
+ const candidateVersion = candidate.slice(1);
63
+ const currentVersionText = current.slice(1);
64
+ if (!parseStableVersion(candidateVersion) || !parseStableVersion(currentVersionText)) return false;
65
+ return compareStableVersions(candidateVersion, currentVersionText) < 0;
66
+ }
67
+
68
+ function cleanupOldBinaryVersions(options = {}) {
69
+ if (process.env.MOONDESK_BINARY_CACHE_DIR && !options.cacheRoot) {
70
+ return { removed: [], skipped: [] };
71
+ }
72
+
73
+ const cacheRoot = options.cacheRoot ?? defaultBinaryCacheRoot();
74
+ const keepTag = options.keepTag ?? releaseTag;
75
+ const removed = [];
76
+ const skipped = [];
77
+
78
+ let entries;
79
+ try {
80
+ entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
81
+ } catch (error) {
82
+ if (error.code === "ENOENT") {
83
+ return { removed, skipped };
84
+ }
85
+ throw error;
86
+ }
87
+
88
+ for (const entry of entries) {
89
+ if (!entry.isDirectory() || !stableTagIsOlder(entry.name, keepTag)) {
90
+ continue;
91
+ }
92
+ const stalePath = path.join(cacheRoot, entry.name);
93
+ try {
94
+ fs.rmSync(stalePath, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
95
+ removed.push(entry.name);
96
+ } catch {
97
+ // Another still-running MoonDesk process may hold an older Windows binary open.
98
+ // Leave it in place and retry on the next managed launch.
99
+ skipped.push(entry.name);
100
+ }
101
+ }
102
+
103
+ return { removed, skipped };
53
104
  }
54
105
 
55
106
  async function fetchRequired(fetchImpl, url, maxBytes, timeoutMs = METADATA_TIMEOUT_MS) {
@@ -331,6 +382,7 @@ async function ensureBinary(options = {}) {
331
382
  }
332
383
 
333
384
  module.exports = {
385
+ cleanupOldBinaryVersions,
334
386
  ensureBinary,
335
387
  expectedSha256,
336
388
  resolveTarget,
package/npm/moondesk.js CHANGED
@@ -1,40 +1,250 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const fs = require("node:fs");
3
4
  const { spawn } = require("node:child_process");
4
- const { ensureBinary } = require("./install-binary");
5
+ const { cleanupOldBinaryVersions, ensureBinary } = require("./install-binary");
6
+ const {
7
+ UPDATE_EXIT_CODE,
8
+ acquireUpdateLock,
9
+ cleanupOldUpdateVersions,
10
+ compareStableVersions,
11
+ createUpdateRequestPath,
12
+ createUpdateStatePath,
13
+ installExactVersion,
14
+ installedWrapperVersion,
15
+ readUpdateRequest,
16
+ restartUpdatedWrapper,
17
+ startUpdateMonitor,
18
+ verifyInstalledWrapperVersion,
19
+ } = require("./update-manager");
20
+
21
+ function cleanManagedUpdateEnv(source = process.env) {
22
+ const env = { ...source };
23
+ delete env.MOONDESK_NPM_MANAGED;
24
+ delete env.MOONDESK_UPDATE_REQUEST_PATH;
25
+ delete env.MOONDESK_UPDATE_STATE_PATH;
26
+ return env;
27
+ }
28
+
29
+ function cleanupEphemeralUpdateFiles(statePath, requestPath) {
30
+ for (const filePath of [statePath, requestPath]) {
31
+ if (!filePath) continue;
32
+ try {
33
+ fs.rmSync(filePath, { force: true });
34
+ } catch {
35
+ // Ephemeral updater metadata must never make normal MoonDesk startup or shutdown fail.
36
+ }
37
+ }
38
+ }
39
+
40
+ function runNative(binaryPath, args, options = {}) {
41
+ const spawnImpl = options.spawnImpl ?? spawn;
42
+ return new Promise((resolve, reject) => {
43
+ const child = spawnImpl(binaryPath, args, {
44
+ cwd: options.cwd ?? process.cwd(),
45
+ env: options.env ?? process.env,
46
+ stdio: "inherit",
47
+ });
48
+ child.once("error", reject);
49
+ child.once("exit", (code, signal) => resolve({ code: code ?? 1, signal }));
50
+ });
51
+ }
52
+
53
+ async function orchestrate(options = {}) {
54
+ const logger = options.logger ?? console;
55
+ const originalArgs = options.args ?? process.argv.slice(2);
56
+ const originalCwd = options.cwd ?? process.cwd();
57
+ const baseEnv = cleanManagedUpdateEnv(options.env ?? process.env);
58
+ const createUpdateStatePathImpl = options.createUpdateStatePathImpl ?? createUpdateStatePath;
59
+ const createUpdateRequestPathImpl = options.createUpdateRequestPathImpl ?? createUpdateRequestPath;
60
+ let updateStatePath = options.updateStatePath ?? null;
61
+ let updateRequestPath = options.updateRequestPath ?? null;
62
+ let selfUpdateEnabled = true;
63
+ try {
64
+ updateStatePath = updateStatePath ?? createUpdateStatePathImpl();
65
+ updateRequestPath = updateRequestPath ?? createUpdateRequestPathImpl();
66
+ } catch (error) {
67
+ if (!options.updateStatePath) cleanupEphemeralUpdateFiles(updateStatePath, null);
68
+ if (!options.updateRequestPath) cleanupEphemeralUpdateFiles(null, updateRequestPath);
69
+ updateStatePath = null;
70
+ updateRequestPath = null;
71
+ selfUpdateEnabled = false;
72
+ logger.warn?.(
73
+ `MoonDesk disabled in-app self-update because it could not prepare update metadata: ${error.message}`,
74
+ );
75
+ }
76
+ const ensureBinaryImpl = options.ensureBinaryImpl ?? ensureBinary;
77
+ const cleanupOldBinaryVersionsImpl = options.cleanupOldBinaryVersionsImpl ?? cleanupOldBinaryVersions;
78
+ const cleanupOldUpdateVersionsImpl = options.cleanupOldUpdateVersionsImpl ?? cleanupOldUpdateVersions;
79
+ const startUpdateMonitorImpl = options.startUpdateMonitorImpl ?? startUpdateMonitor;
80
+ const runNativeImpl = options.runNativeImpl ?? runNative;
81
+ const readUpdateRequestImpl = options.readUpdateRequestImpl ?? readUpdateRequest;
82
+ const acquireUpdateLockImpl = options.acquireUpdateLockImpl ?? acquireUpdateLock;
83
+ const installedWrapperVersionImpl = options.installedWrapperVersionImpl ?? installedWrapperVersion;
84
+ const installExactVersionImpl = options.installExactVersionImpl ?? installExactVersion;
85
+ const verifyInstalledWrapperVersionImpl =
86
+ options.verifyInstalledWrapperVersionImpl ?? verifyInstalledWrapperVersion;
87
+ const restartUpdatedWrapperImpl = options.restartUpdatedWrapperImpl ?? restartUpdatedWrapper;
88
+ const wrapperPath = options.wrapperPath ?? __filename;
89
+
90
+ const stopUpdateMonitor = selfUpdateEnabled
91
+ ? startUpdateMonitorImpl({
92
+ statePath: updateStatePath,
93
+ cwd: originalCwd,
94
+ env: baseEnv,
95
+ })
96
+ : () => {};
5
97
 
6
- async function main() {
7
98
  let binaryPath;
8
99
  try {
9
- binaryPath = await ensureBinary();
100
+ binaryPath = await ensureBinaryImpl();
10
101
  } catch (error) {
11
- console.error(`MoonDesk could not prepare its native binary: ${error.message}`);
12
- console.error("Check your network connection and the matching GitHub Release, then run MoonDesk again.");
13
- process.exit(1);
14
- return;
102
+ stopUpdateMonitor();
103
+ cleanupEphemeralUpdateFiles(updateStatePath, updateRequestPath);
104
+ logger.error(`MoonDesk could not prepare its native binary: ${error.message}`);
105
+ logger.error(
106
+ "Check your network connection and the matching GitHub Release, then run MoonDesk again.",
107
+ );
108
+ return { code: 1, signal: null };
15
109
  }
16
110
 
17
- const child = spawn(binaryPath, process.argv.slice(2), {
18
- cwd: process.cwd(),
19
- env: process.env,
20
- stdio: "inherit",
21
- });
111
+ try {
112
+ cleanupOldBinaryVersionsImpl();
113
+ } catch (error) {
114
+ // Cache cleanup is best-effort. A locked old Windows executable must not stop MoonDesk.
115
+ logger.warn?.(`MoonDesk could not remove an older native cache yet: ${error.message}`);
116
+ }
117
+ try {
118
+ cleanupOldUpdateVersionsImpl();
119
+ } catch (error) {
120
+ logger.warn?.(`MoonDesk could not remove older update metadata yet: ${error.message}`);
121
+ }
22
122
 
23
- child.on("error", (error) => {
24
- console.error(`MoonDesk failed to start: ${error.message}`);
25
- process.exit(1);
26
- });
123
+ const childEnv = { ...baseEnv };
124
+ if (selfUpdateEnabled) {
125
+ Object.assign(childEnv, {
126
+ MOONDESK_NPM_MANAGED: "1",
127
+ MOONDESK_UPDATE_REQUEST_PATH: updateRequestPath,
128
+ MOONDESK_UPDATE_STATE_PATH: updateStatePath,
129
+ });
130
+ }
131
+
132
+ let result;
133
+ try {
134
+ result = await runNativeImpl(binaryPath, originalArgs, {
135
+ cwd: originalCwd,
136
+ env: childEnv,
137
+ });
138
+ } catch (error) {
139
+ stopUpdateMonitor();
140
+ cleanupEphemeralUpdateFiles(updateStatePath, updateRequestPath);
141
+ logger.error(`MoonDesk failed to start: ${error.message}`);
142
+ return { code: 1, signal: null };
143
+ }
144
+
145
+ stopUpdateMonitor();
146
+ cleanupEphemeralUpdateFiles(updateStatePath, null);
147
+
148
+ if (result.signal) {
149
+ cleanupEphemeralUpdateFiles(null, updateRequestPath);
150
+ return result;
151
+ }
152
+
153
+ if (result.code !== UPDATE_EXIT_CODE) {
154
+ cleanupEphemeralUpdateFiles(null, updateRequestPath);
155
+ return { code: result.code ?? 1, signal: null };
156
+ }
157
+
158
+ if (!selfUpdateEnabled || !updateRequestPath) {
159
+ logger.error("MoonDesk requested an update restart, but in-app self-update is unavailable for this launch.");
160
+ return { code: 1, signal: null };
161
+ }
162
+
163
+ const request = readUpdateRequestImpl(updateRequestPath);
164
+ if (!request) {
165
+ logger.error(
166
+ "MoonDesk requested an update restart, but its validated update request was missing or invalid.",
167
+ );
168
+ return { code: 1, signal: null };
169
+ }
27
170
 
28
- child.on("exit", (code, signal) => {
29
- if (signal) {
30
- process.kill(process.pid, signal);
31
- return;
171
+ logger.log(`Updating MoonDesk ${request.currentVersion} -> ${request.targetVersion}...`);
172
+ let releaseUpdateLock = null;
173
+ let restartVersion = request.targetVersion;
174
+ try {
175
+ releaseUpdateLock = await acquireUpdateLockImpl({
176
+ cwd: originalCwd,
177
+ env: baseEnv,
178
+ });
179
+
180
+ const alreadyInstalled = installedWrapperVersionImpl();
181
+ const comparison = compareStableVersions(alreadyInstalled, request.targetVersion);
182
+ if (comparison < 0) {
183
+ await installExactVersionImpl(request.targetVersion, {
184
+ cwd: originalCwd,
185
+ env: baseEnv,
186
+ });
187
+ verifyInstalledWrapperVersionImpl(request.targetVersion);
188
+ } else if (comparison === 0) {
189
+ logger.log(`MoonDesk ${request.targetVersion} was already installed by another process.`);
190
+ } else {
191
+ restartVersion = alreadyInstalled;
192
+ logger.log(
193
+ `MoonDesk ${alreadyInstalled} is already installed, so the updater will not downgrade it to ${request.targetVersion}.`,
194
+ );
32
195
  }
33
- process.exit(code ?? 1);
34
- });
196
+ } catch (error) {
197
+ logger.error(`MoonDesk update failed: ${error.message}`);
198
+ logger.error(
199
+ `Run 'npm install -g moondesk@${request.targetVersion}' manually to retry this exact version.`,
200
+ );
201
+ return { code: 1, signal: null };
202
+ } finally {
203
+ try {
204
+ releaseUpdateLock?.();
205
+ } catch (error) {
206
+ logger.warn?.(`MoonDesk could not remove its npm update lock yet: ${error.message}`);
207
+ }
208
+ }
209
+
210
+ logger.log(`MoonDesk ${restartVersion} is installed. Restarting...`);
211
+ try {
212
+ return await restartUpdatedWrapperImpl(wrapperPath, originalArgs, {
213
+ cwd: originalCwd,
214
+ env: baseEnv,
215
+ });
216
+ } catch (error) {
217
+ logger.error(`MoonDesk updated successfully but could not restart automatically: ${error.message}`);
218
+ logger.error("Run 'moondesk' again to start the updated version.");
219
+ return { code: 1, signal: null };
220
+ }
221
+ }
222
+
223
+ function finish(result) {
224
+ if (result.signal) {
225
+ try {
226
+ process.kill(process.pid, result.signal);
227
+ } catch (error) {
228
+ console.error(`MoonDesk could not propagate ${result.signal}: ${error.message}`);
229
+ process.exitCode = 1;
230
+ }
231
+ return;
232
+ }
233
+ process.exitCode = result.code ?? 1;
234
+ }
235
+
236
+ if (require.main === module) {
237
+ orchestrate()
238
+ .then(finish)
239
+ .catch((error) => {
240
+ console.error(`MoonDesk failed to start: ${error.message}`);
241
+ process.exitCode = 1;
242
+ });
35
243
  }
36
244
 
37
- main().catch((error) => {
38
- console.error(`MoonDesk failed to start: ${error.message}`);
39
- process.exit(1);
40
- });
245
+ module.exports = {
246
+ cleanManagedUpdateEnv,
247
+ cleanupEphemeralUpdateFiles,
248
+ orchestrate,
249
+ runNative,
250
+ };
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env node
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const os = require("node:os");
6
+ const path = require("node:path");
7
+ const { spawn } = require("node:child_process");
8
+
9
+ const packageRoot = path.resolve(__dirname, "..");
10
+ const packageJsonPath = path.join(packageRoot, "package.json");
11
+ const packageJson = require(packageJsonPath);
12
+ const currentVersion = packageJson.version;
13
+
14
+ const UPDATE_EXIT_CODE = 75;
15
+ const UPDATE_STATE_SCHEMA_VERSION = 1;
16
+ const UPDATE_REQUEST_SCHEMA_VERSION = 1;
17
+ const REGISTRY_LATEST_URL = "https://registry.npmjs.org/moondesk/latest";
18
+ const UPDATE_CHECK_INTERVAL_MS = 15 * 60_000;
19
+ const UPDATE_CHECK_TIMEOUT_MS = 15_000;
20
+ const MAX_UPDATE_METADATA_BYTES = 64 * 1024;
21
+ const MAX_UPDATE_REQUEST_BYTES = 16 * 1024;
22
+ const MAX_NPM_ROOT_BYTES = 16 * 1024;
23
+ const NPM_ROOT_TIMEOUT_MS = 10_000;
24
+ const UPDATE_LOCK_WAIT_MS = 60_000;
25
+ const UPDATE_LOCK_POLL_MS = 200;
26
+
27
+ function parseStableVersion(input) {
28
+ if (typeof input !== "string") {
29
+ return null;
30
+ }
31
+ const match = input.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
32
+ if (!match) {
33
+ return null;
34
+ }
35
+ return match.slice(1).map((part) => BigInt(part));
36
+ }
37
+
38
+ function compareStableVersions(left, right) {
39
+ const a = parseStableVersion(left);
40
+ const b = parseStableVersion(right);
41
+ if (!a || !b) {
42
+ throw new Error(`MoonDesk update versions must be stable semantic versions: ${left} vs ${right}`);
43
+ }
44
+ for (let index = 0; index < 3; index += 1) {
45
+ if (a[index] < b[index]) return -1;
46
+ if (a[index] > b[index]) return 1;
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ function updateRootDir() {
52
+ return path.join(os.homedir(), ".moondesk", "updates");
53
+ }
54
+
55
+ function currentUpdateDir() {
56
+ return path.join(updateRootDir(), `v${currentVersion}`);
57
+ }
58
+
59
+ function createUpdateStatePath() {
60
+ const dir = path.join(currentUpdateDir(), "state");
61
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
62
+ return path.join(dir, `${process.pid}-${crypto.randomBytes(12).toString("hex")}.json`);
63
+ }
64
+
65
+ function cleanupOldUpdateVersions(options = {}) {
66
+ const root = options.root ?? updateRootDir();
67
+ const currentTag = options.currentTag ?? `v${currentVersion}`;
68
+ const currentVersionText = currentTag.startsWith("v") ? currentTag.slice(1) : currentTag;
69
+ const currentParts = parseStableVersion(currentVersionText);
70
+ if (!currentParts) {
71
+ return { removed: [], skipped: [] };
72
+ }
73
+
74
+ const removed = [];
75
+ const skipped = [];
76
+ let entries;
77
+ try {
78
+ entries = fs.readdirSync(root, { withFileTypes: true });
79
+ } catch (error) {
80
+ if (error.code === "ENOENT") return { removed, skipped };
81
+ throw error;
82
+ }
83
+
84
+ for (const entry of entries) {
85
+ if (!entry.isDirectory() || !entry.name.startsWith("v")) continue;
86
+ const candidate = entry.name.slice(1);
87
+ const candidateParts = parseStableVersion(candidate);
88
+ if (!candidateParts || compareStableVersions(candidate, currentVersionText) >= 0) continue;
89
+ try {
90
+ fs.rmSync(path.join(root, entry.name), {
91
+ recursive: true,
92
+ force: true,
93
+ maxRetries: 2,
94
+ retryDelay: 100,
95
+ });
96
+ removed.push(entry.name);
97
+ } catch {
98
+ skipped.push(entry.name);
99
+ }
100
+ }
101
+ return { removed, skipped };
102
+ }
103
+
104
+ function updateRequestDir() {
105
+ return path.join(currentUpdateDir(), "requests");
106
+ }
107
+
108
+ function createUpdateRequestPath() {
109
+ const dir = updateRequestDir();
110
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
111
+ return path.join(dir, `${process.pid}-${crypto.randomBytes(12).toString("hex")}.json`);
112
+ }
113
+
114
+ function normalizePathForCompare(value, platform = process.platform) {
115
+ const resolved = path.resolve(value);
116
+ let canonical = resolved;
117
+ try {
118
+ canonical = fs.realpathSync.native(resolved);
119
+ } catch {
120
+ // The expected global package path may not exist in unit tests.
121
+ }
122
+ return platform === "win32" ? canonical.toLowerCase() : canonical;
123
+ }
124
+
125
+ function resolveGlobalNpmRoot(options = {}) {
126
+ const spawnImpl = options.spawnImpl ?? spawn;
127
+ const platform = options.platform ?? process.platform;
128
+ const command = npmExecutable(platform);
129
+ const env = options.env ?? process.env;
130
+ const externalSignal = options.signal;
131
+
132
+ return new Promise((resolve, reject) => {
133
+ const child = spawnImpl(command, ["root", "--global"], {
134
+ cwd: options.cwd ?? process.cwd(),
135
+ env,
136
+ stdio: ["ignore", "pipe", "ignore"],
137
+ shell: platform === "win32",
138
+ windowsHide: true,
139
+ });
140
+
141
+ let settled = false;
142
+ let timer = null;
143
+ let abortHandler = null;
144
+ let output = Buffer.alloc(0);
145
+ const finish = (error, value) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ if (timer) clearTimeout(timer);
149
+ if (abortHandler) externalSignal?.removeEventListener("abort", abortHandler);
150
+ if (error) reject(error);
151
+ else resolve(value);
152
+ };
153
+
154
+ abortHandler = () => {
155
+ child.kill?.();
156
+ finish(new Error("npm root --global was aborted"));
157
+ };
158
+ if (externalSignal?.aborted) {
159
+ abortHandler();
160
+ return;
161
+ }
162
+ externalSignal?.addEventListener("abort", abortHandler, { once: true });
163
+
164
+ timer = setTimeout(() => {
165
+ child.kill?.();
166
+ finish(new Error("npm root --global timed out"));
167
+ }, NPM_ROOT_TIMEOUT_MS);
168
+ timer.unref?.();
169
+
170
+ child.once("error", (error) => finish(error));
171
+ child.stdout?.on("data", (chunk) => {
172
+ if (settled) return;
173
+ output = Buffer.concat([output, Buffer.from(chunk)]);
174
+ if (output.length > MAX_NPM_ROOT_BYTES) {
175
+ child.kill?.();
176
+ finish(new Error("npm root --global returned too much output"));
177
+ }
178
+ });
179
+ child.once("exit", (code, signal) => {
180
+ if (settled) return;
181
+ if (signal) {
182
+ finish(new Error(`npm root --global was terminated by ${signal}`));
183
+ return;
184
+ }
185
+ if (code !== 0) {
186
+ finish(new Error(`npm root --global exited with code ${code ?? "unknown"}`));
187
+ return;
188
+ }
189
+ const root = output.toString("utf8").trim();
190
+ if (!root) {
191
+ finish(new Error("npm root --global returned an empty path"));
192
+ return;
193
+ }
194
+ finish(null, root);
195
+ });
196
+ });
197
+ }
198
+
199
+ async function isGlobalPackageInstall(options = {}) {
200
+ const root = await resolveGlobalNpmRoot(options);
201
+ const platform = options.platform ?? process.platform;
202
+ const actualRoot = options.packageRoot ?? packageRoot;
203
+ const expectedRoot = path.join(root, "moondesk");
204
+ return normalizePathForCompare(actualRoot, platform) === normalizePathForCompare(expectedRoot, platform);
205
+ }
206
+
207
+ function replaceFile(tempPath, destinationPath, platform = process.platform) {
208
+ try {
209
+ fs.renameSync(tempPath, destinationPath);
210
+ return;
211
+ } catch (error) {
212
+ const replaceConflict = ["EEXIST", "EPERM", "EACCES"].includes(error.code);
213
+ if (platform !== "win32" || !replaceConflict) {
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ // Windows can reject rename-over-existing-file while another process briefly
219
+ // has the destination open. The TUI treats a missing state file as "no update",
220
+ // so a tiny replacement gap is safer than failing MoonDesk startup.
221
+ fs.rmSync(destinationPath, { force: true });
222
+ fs.renameSync(tempPath, destinationPath);
223
+ }
224
+
225
+ function atomicWriteJson(filePath, value, options = {}) {
226
+ const dir = path.dirname(filePath);
227
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
228
+ const temp = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
229
+ try {
230
+ fs.writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
231
+ replaceFile(temp, filePath, options.platform ?? process.platform);
232
+ } finally {
233
+ fs.rmSync(temp, { force: true });
234
+ }
235
+ }
236
+
237
+ async function fetchJsonLimited(fetchImpl, url, externalSignal) {
238
+ const controller = new AbortController();
239
+ const abortFromParent = () => controller.abort();
240
+ if (externalSignal) {
241
+ if (externalSignal.aborted) {
242
+ controller.abort();
243
+ } else {
244
+ externalSignal.addEventListener("abort", abortFromParent, { once: true });
245
+ }
246
+ }
247
+ const timeout = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS);
248
+ timeout.unref?.();
249
+
250
+ try {
251
+ const response = await fetchImpl(url, {
252
+ headers: {
253
+ Accept: "application/json",
254
+ "User-Agent": `moondesk-npm/${currentVersion}`,
255
+ },
256
+ signal: controller.signal,
257
+ });
258
+ if (!response.ok) {
259
+ throw new Error(`${url} returned HTTP ${response.status}`);
260
+ }
261
+ const contentLength = Number(response.headers.get("content-length"));
262
+ if (Number.isFinite(contentLength) && contentLength > MAX_UPDATE_METADATA_BYTES) {
263
+ controller.abort();
264
+ throw new Error(`MoonDesk update metadata is unexpectedly large (${contentLength} bytes)`);
265
+ }
266
+ if (!response.body || typeof response.body[Symbol.asyncIterator] !== "function") {
267
+ throw new Error("MoonDesk update metadata response did not include a readable body");
268
+ }
269
+
270
+ const chunks = [];
271
+ let totalBytes = 0;
272
+ for await (const chunk of response.body) {
273
+ const buffer = Buffer.from(chunk);
274
+ totalBytes += buffer.length;
275
+ if (totalBytes > MAX_UPDATE_METADATA_BYTES) {
276
+ controller.abort();
277
+ throw new Error("MoonDesk update metadata exceeded the download limit");
278
+ }
279
+ chunks.push(buffer);
280
+ }
281
+ return JSON.parse(Buffer.concat(chunks, totalBytes).toString("utf8"));
282
+ } finally {
283
+ clearTimeout(timeout);
284
+ externalSignal?.removeEventListener("abort", abortFromParent);
285
+ }
286
+ }
287
+
288
+ async function checkForUpdate(options = {}) {
289
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
290
+ const statePath = options.statePath ?? createUpdateStatePath();
291
+ const registryUrl = options.registryUrl ?? REGISTRY_LATEST_URL;
292
+ const managedInstall = options.managedInstall === true;
293
+ if (typeof fetchImpl !== "function") {
294
+ return null;
295
+ }
296
+
297
+ const metadata = await fetchJsonLimited(fetchImpl, registryUrl, options.signal);
298
+ const latestVersion = metadata?.version;
299
+ if (metadata?.name !== "moondesk") {
300
+ throw new Error(`npm returned unexpected package metadata for ${String(metadata?.name)}`);
301
+ }
302
+ if (!parseStableVersion(latestVersion)) {
303
+ throw new Error(`npm returned an invalid MoonDesk version: ${String(latestVersion)}`);
304
+ }
305
+ if (typeof metadata?.dist?.integrity !== "string" || !metadata.dist.integrity.startsWith("sha512-")) {
306
+ throw new Error("npm returned MoonDesk metadata without a sha512 package integrity value");
307
+ }
308
+
309
+ const available = managedInstall && compareStableVersions(latestVersion, currentVersion) > 0;
310
+ const state = {
311
+ schemaVersion: UPDATE_STATE_SCHEMA_VERSION,
312
+ packageName: "moondesk",
313
+ currentVersion,
314
+ latestVersion,
315
+ managedInstall,
316
+ available,
317
+ checkedAt: new Date().toISOString(),
318
+ };
319
+ atomicWriteJson(statePath, state, options);
320
+ return state;
321
+ }
322
+
323
+ function startUpdateMonitor(options = {}) {
324
+ const intervalMs = options.intervalMs ?? UPDATE_CHECK_INTERVAL_MS;
325
+ const statePath = options.statePath ?? createUpdateStatePath();
326
+ const globalInstallCheckImpl = options.isGlobalPackageInstallImpl ?? isGlobalPackageInstall;
327
+ let managedInstall = null;
328
+ let stopped = false;
329
+ let checking = false;
330
+ let timer = null;
331
+ let controller = null;
332
+
333
+ const run = async () => {
334
+ if (stopped || checking) return;
335
+ checking = true;
336
+ controller = new AbortController();
337
+ try {
338
+ if (managedInstall === null) {
339
+ managedInstall = await globalInstallCheckImpl({ ...options, signal: controller.signal });
340
+ }
341
+ if (!managedInstall || stopped) {
342
+ fs.rmSync(statePath, { force: true });
343
+ return;
344
+ }
345
+ await checkForUpdate({
346
+ ...options,
347
+ statePath,
348
+ managedInstall: true,
349
+ signal: controller.signal,
350
+ });
351
+ } catch {
352
+ // Update checks are optional. Offline/npm/registry failures must never affect MoonDesk startup.
353
+ } finally {
354
+ if (stopped) {
355
+ try {
356
+ fs.rmSync(statePath, { force: true });
357
+ } catch {
358
+ // Removing ephemeral update state must never fail MoonDesk.
359
+ }
360
+ }
361
+ controller = null;
362
+ checking = false;
363
+ }
364
+ };
365
+
366
+ void run();
367
+ timer = setInterval(() => void run(), intervalMs);
368
+ timer.unref?.();
369
+
370
+ return () => {
371
+ stopped = true;
372
+ controller?.abort();
373
+ if (timer) clearInterval(timer);
374
+ };
375
+ }
376
+
377
+ function readUpdateRequest(requestPath) {
378
+ let parsed;
379
+ try {
380
+ const stat = fs.statSync(requestPath);
381
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_UPDATE_REQUEST_BYTES) {
382
+ return null;
383
+ }
384
+ parsed = JSON.parse(fs.readFileSync(requestPath, "utf8"));
385
+ } catch {
386
+ return null;
387
+ } finally {
388
+ fs.rmSync(requestPath, { force: true });
389
+ }
390
+
391
+ if (
392
+ parsed?.schemaVersion !== UPDATE_REQUEST_SCHEMA_VERSION ||
393
+ parsed.currentVersion !== currentVersion ||
394
+ !parseStableVersion(parsed.targetVersion) ||
395
+ compareStableVersions(parsed.targetVersion, currentVersion) <= 0
396
+ ) {
397
+ return null;
398
+ }
399
+ return parsed;
400
+ }
401
+
402
+ function updateLockPath() {
403
+ return path.join(updateRootDir(), "npm-update.lock");
404
+ }
405
+
406
+ function lockOwnerIsAlive(lockPath) {
407
+ try {
408
+ const payload = JSON.parse(fs.readFileSync(lockPath, "utf8"));
409
+ const pid = Number(payload?.pid);
410
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
411
+ process.kill(pid, 0);
412
+ return true;
413
+ } catch (error) {
414
+ return error?.code === "EPERM";
415
+ }
416
+ }
417
+
418
+ function sleep(ms) {
419
+ return new Promise((resolve) => setTimeout(resolve, ms));
420
+ }
421
+
422
+ async function acquireUpdateLock(options = {}) {
423
+ const lockPath = options.lockPath ?? updateLockPath();
424
+ const waitMs = options.waitMs ?? UPDATE_LOCK_WAIT_MS;
425
+ const pollMs = options.pollMs ?? UPDATE_LOCK_POLL_MS;
426
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
427
+ const deadline = Date.now() + waitMs;
428
+
429
+ while (true) {
430
+ try {
431
+ const fd = fs.openSync(lockPath, "wx", 0o600);
432
+ try {
433
+ fs.writeFileSync(
434
+ fd,
435
+ `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
436
+ );
437
+ fs.fsyncSync(fd);
438
+ } finally {
439
+ fs.closeSync(fd);
440
+ }
441
+ let released = false;
442
+ return () => {
443
+ if (released) return;
444
+ released = true;
445
+ fs.rmSync(lockPath, { force: true });
446
+ };
447
+ } catch (error) {
448
+ if (error.code !== "EEXIST") throw error;
449
+ if (!lockOwnerIsAlive(lockPath)) {
450
+ fs.rmSync(lockPath, { force: true });
451
+ continue;
452
+ }
453
+ if (Date.now() >= deadline) {
454
+ throw new Error("another MoonDesk process is still updating the global npm package");
455
+ }
456
+ await sleep(pollMs);
457
+ }
458
+ }
459
+ }
460
+
461
+ function installedWrapperVersion(options = {}) {
462
+ const targetPackageJsonPath = options.packageJsonPath ?? packageJsonPath;
463
+ let installed;
464
+ try {
465
+ installed = JSON.parse(fs.readFileSync(targetPackageJsonPath, "utf8"));
466
+ } catch (error) {
467
+ throw new Error(`could not read installed MoonDesk package metadata: ${error.message}`);
468
+ }
469
+ if (installed?.name !== "moondesk" || !parseStableVersion(installed.version)) {
470
+ throw new Error(
471
+ `installed package metadata is not a stable MoonDesk release: ${String(installed?.name)}@${String(installed?.version)}`,
472
+ );
473
+ }
474
+ return installed.version;
475
+ }
476
+
477
+ function npmExecutable(platform = process.platform) {
478
+ return platform === "win32" ? "npm.cmd" : "npm";
479
+ }
480
+
481
+ function installExactVersion(targetVersion, options = {}) {
482
+ if (!parseStableVersion(targetVersion) || compareStableVersions(targetVersion, currentVersion) <= 0) {
483
+ return Promise.reject(new Error(`Refusing invalid MoonDesk update target ${targetVersion}`));
484
+ }
485
+
486
+ const spawnImpl = options.spawnImpl ?? spawn;
487
+ const platform = options.platform ?? process.platform;
488
+ const command = npmExecutable(platform);
489
+ const args = [
490
+ "install",
491
+ "--global",
492
+ `moondesk@${targetVersion}`,
493
+ "--ignore-scripts",
494
+ "--no-audit",
495
+ "--no-fund",
496
+ ];
497
+
498
+ return new Promise((resolve, reject) => {
499
+ const child = spawnImpl(command, args, {
500
+ cwd: options.cwd ?? process.cwd(),
501
+ env: options.env ?? process.env,
502
+ stdio: "inherit",
503
+ shell: platform === "win32",
504
+ });
505
+ child.once("error", reject);
506
+ child.once("exit", (code, signal) => {
507
+ if (signal) {
508
+ reject(new Error(`npm update was terminated by ${signal}`));
509
+ } else if (code !== 0) {
510
+ reject(new Error(`npm install exited with code ${code ?? "unknown"}`));
511
+ } else {
512
+ resolve();
513
+ }
514
+ });
515
+ });
516
+ }
517
+
518
+ function verifyInstalledWrapperVersion(targetVersion, options = {}) {
519
+ if (!parseStableVersion(targetVersion)) {
520
+ throw new Error(`Refusing invalid MoonDesk verification target ${targetVersion}`);
521
+ }
522
+ const installedVersion = installedWrapperVersion(options);
523
+ if (installedVersion !== targetVersion) {
524
+ throw new Error(
525
+ `npm reported success but installed moondesk@${installedVersion} instead of moondesk@${targetVersion}`,
526
+ );
527
+ }
528
+ return true;
529
+ }
530
+
531
+ function restartUpdatedWrapper(wrapperPath, args, options = {}) {
532
+ const spawnImpl = options.spawnImpl ?? spawn;
533
+ return new Promise((resolve, reject) => {
534
+ const child = spawnImpl(process.execPath, [wrapperPath, ...args], {
535
+ cwd: options.cwd ?? process.cwd(),
536
+ env: options.env ?? process.env,
537
+ stdio: "inherit",
538
+ });
539
+ child.once("error", reject);
540
+ child.once("exit", (code, signal) => resolve({ code: code ?? 1, signal }));
541
+ });
542
+ }
543
+
544
+ module.exports = {
545
+ UPDATE_EXIT_CODE,
546
+ acquireUpdateLock,
547
+ atomicWriteJson,
548
+ checkForUpdate,
549
+ cleanupOldUpdateVersions,
550
+ compareStableVersions,
551
+ createUpdateRequestPath,
552
+ createUpdateStatePath,
553
+ currentVersion,
554
+ installExactVersion,
555
+ installedWrapperVersion,
556
+ isGlobalPackageInstall,
557
+ parseStableVersion,
558
+ readUpdateRequest,
559
+ resolveGlobalNpmRoot,
560
+ restartUpdatedWrapper,
561
+ startUpdateMonitor,
562
+ verifyInstalledWrapperVersion,
563
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moondesk",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "MoonDesk — use ChatGPT Chat as a local coding agent, by Shattermoon.",
5
5
  "author": "Shattermoon",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "npm/moondesk.js",
20
20
  "npm/install-binary.js",
21
+ "npm/update-manager.js",
21
22
  "LICENSE",
22
23
  "README.md"
23
24
  ],