resumecontext 0.1.0 → 0.1.1
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/agentConfigWithDaemon.js +2 -8
- package/dist/commands/auth.js +2 -4
- package/dist/commands/daemon.js +9 -7
- package/dist/commands/uninstall.js +57 -8
- package/dist/daemon.js +73 -3
- package/dist/daemonNotices.js +18 -0
- package/dist/daemonService.js +18 -2
- package/dist/index.js +1 -1
- package/dist/packageInstall.js +87 -0
- package/package.json +1 -1
|
@@ -17,13 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { ensureAgentConfig } from "./agentConfig.js";
|
|
19
19
|
import { registerProject, ensureDaemonRunning } from "./daemon.js";
|
|
20
|
-
import
|
|
21
|
-
function warnIfDaemonUnsupported(ensureDaemonRunningFn) {
|
|
22
|
-
const { platform } = ensureDaemonRunningFn();
|
|
23
|
-
if (platform === "unsupported") {
|
|
24
|
-
ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
|
|
25
|
-
}
|
|
26
|
-
}
|
|
20
|
+
import { printDaemonNotice } from "./daemonNotices.js";
|
|
27
21
|
/** For every project-scoped command except `agents`: returns the config
|
|
28
22
|
* that's already on disk, or runs the interactive prompt the first time --
|
|
29
23
|
* either way, makes sure the daemon covers this project afterward. This is
|
|
@@ -37,6 +31,6 @@ function warnIfDaemonUnsupported(ensureDaemonRunningFn) {
|
|
|
37
31
|
export async function ensureAgentConfigWithDaemon(projectId, root, ensureDaemonRunningFn = ensureDaemonRunning) {
|
|
38
32
|
const config = await ensureAgentConfig(projectId);
|
|
39
33
|
registerProject(projectId, root);
|
|
40
|
-
|
|
34
|
+
printDaemonNotice(ensureDaemonRunningFn());
|
|
41
35
|
return config;
|
|
42
36
|
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -10,11 +10,9 @@ import { openInBrowser } from "../browser.js";
|
|
|
10
10
|
import { sleep } from "../utils.js";
|
|
11
11
|
import * as ui from "../ui.js";
|
|
12
12
|
import { ensureDaemonRunning } from "../daemon.js";
|
|
13
|
+
import { printDaemonNotice } from "../daemonNotices.js";
|
|
13
14
|
function startDaemonAfterAuth(ensureDaemonRunningFn) {
|
|
14
|
-
|
|
15
|
-
if (platform === "unsupported") {
|
|
16
|
-
ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
|
|
17
|
-
}
|
|
15
|
+
printDaemonNotice(ensureDaemonRunningFn());
|
|
18
16
|
}
|
|
19
17
|
async function pollForLogin(deps, deviceCode, pollIntervalMs, timeoutMs) {
|
|
20
18
|
const deadline = Date.now() + timeoutMs;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* this is rarely needed day to day -- it's here for after an explicit
|
|
17
17
|
* `stop`, or to check "did that actually work" without waiting for the
|
|
18
18
|
* next `sync`. */
|
|
19
|
-
import { isServiceActive,
|
|
20
|
-
import { readRegistry } from "../daemon.js";
|
|
19
|
+
import { isServiceActive, uninstallPersistentService, detectPlatform } from "../daemonService.js";
|
|
20
|
+
import { ensureDaemonRunning, readRegistry } from "../daemon.js";
|
|
21
|
+
import { printDaemonNotice } from "../daemonNotices.js";
|
|
21
22
|
import { findProjectRoot } from "../projectRoot.js";
|
|
22
23
|
import * as ui from "../ui.js";
|
|
23
24
|
/** Resolve each registry entry exactly as a daemon tick does, so status can
|
|
@@ -75,15 +76,16 @@ export async function runDaemonStatus() {
|
|
|
75
76
|
ui.outro(running ? "Running." : "Not running.");
|
|
76
77
|
return { running, projects };
|
|
77
78
|
}
|
|
78
|
-
export async function runDaemonStart() {
|
|
79
|
+
export async function runDaemonStart(ensureDaemonRunningFn = ensureDaemonRunning) {
|
|
79
80
|
ui.intro("daemon start");
|
|
80
81
|
const platform = detectPlatform();
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
const result = platform === "unsupported" ? { platform, installed: false } : ensureDaemonRunningFn();
|
|
83
|
+
printDaemonNotice(result);
|
|
84
|
+
if (result.platform === "unsupported" || result.location === "temporary") {
|
|
85
|
+
ui.outro(result.platform === "unsupported" ? "Not available." : "Not started.");
|
|
84
86
|
return { started: false };
|
|
85
87
|
}
|
|
86
|
-
const { installed } =
|
|
88
|
+
const { installed } = result;
|
|
87
89
|
ui.outro(installed
|
|
88
90
|
? `Auto-sync started (registered with ${platform === "launchd" ? "launchd" : "systemd"} -- survives crashes and reboots).`
|
|
89
91
|
: "Auto-sync was already running.");
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `resumecontext uninstall` --
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* `resumecontext uninstall` -- removes every trace of the CLI from this
|
|
3
|
+
* machine:
|
|
4
|
+
*
|
|
5
|
+
* - the background auto-sync schedule (launchd plist / systemd units), and
|
|
6
|
+
* any sync tick running at that moment;
|
|
7
|
+
* - all local state: credentials, device id, every project's sync progress
|
|
8
|
+
* and agent configuration, the daemon's project registry and log;
|
|
9
|
+
* - the globally installed npm package itself, when that is how it was
|
|
10
|
+
* installed (otherwise it says how to remove it).
|
|
11
|
+
*
|
|
12
|
+
* Deliberately left: each project's `.resumecontext.json` marker. It lives in
|
|
13
|
+
* the user's repository and is meant to be committed and shared, so deleting
|
|
14
|
+
* it would change teammates' checkouts. uninstall lists where they are.
|
|
15
|
+
*
|
|
16
|
+
* This is the one command in the whole CLI that can't be undone by running
|
|
17
|
+
* another command afterward, so it asks for confirmation first, defaulting
|
|
18
|
+
* to No.
|
|
8
19
|
*/
|
|
9
20
|
import fs from "node:fs";
|
|
10
21
|
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
11
23
|
import { uninstallPersistentService } from "../daemonService.js";
|
|
24
|
+
import { readRegistry, stopRunningTick } from "../daemon.js";
|
|
25
|
+
import { isGlobalNpmInstall, removeGlobalNpmPackage, PACKAGE_NAME } from "../packageInstall.js";
|
|
26
|
+
import { MARKER_FILENAME } from "../projectRoot.js";
|
|
12
27
|
import { defaultResumecontextHome, KNOWN_HOME_ENTRIES, resumecontextHome } from "../paths.js";
|
|
13
28
|
import * as ui from "../ui.js";
|
|
14
29
|
/** True if it's safe to delete `dir` as resumecontext state. The default
|
|
@@ -27,9 +42,17 @@ function looksLikeResumecontextHome(dir) {
|
|
|
27
42
|
return true;
|
|
28
43
|
return entries.some((entry) => KNOWN_HOME_ENTRIES.has(entry));
|
|
29
44
|
}
|
|
45
|
+
/** Project folders registered on this machine that still hold a marker. */
|
|
46
|
+
function projectMarkers() {
|
|
47
|
+
const roots = new Set(Object.values(readRegistry().projects).map((p) => p.root));
|
|
48
|
+
return [...roots].map((root) => path.join(root, MARKER_FILENAME)).filter((file) => fs.existsSync(file));
|
|
49
|
+
}
|
|
30
50
|
export async function runUninstall(opts = {}) {
|
|
31
51
|
const confirmFn = opts.confirmFn ?? ((message) => ui.confirm(message, false));
|
|
32
52
|
const uninstallService = opts.uninstallService ?? uninstallPersistentService;
|
|
53
|
+
const stopTick = opts.stopTick ?? (() => stopRunningTick());
|
|
54
|
+
const isGlobalInstall = opts.isGlobalInstall ?? (() => isGlobalNpmInstall());
|
|
55
|
+
const removePackage = opts.removePackage ?? (() => removeGlobalNpmPackage());
|
|
33
56
|
ui.intro("uninstall");
|
|
34
57
|
const home = resumecontextHome();
|
|
35
58
|
// A misconfigured RESUMECONTEXT_HOME (empty, "/", or the real home
|
|
@@ -46,16 +69,42 @@ export async function runUninstall(opts = {}) {
|
|
|
46
69
|
if (!looksLikeResumecontextHome(home)) {
|
|
47
70
|
throw new Error(`Refusing to delete "${home}" -- it contains files resumecontext didn't create, so this doesn't look like a resumecontext state directory.`);
|
|
48
71
|
}
|
|
72
|
+
const globalInstall = isGlobalInstall();
|
|
73
|
+
// Read before the registry is deleted along with everything else.
|
|
74
|
+
const markers = projectMarkers();
|
|
49
75
|
ui.log.warn("This will stop auto-sync and permanently delete:");
|
|
50
76
|
ui.log.warn(` ${home}`);
|
|
51
|
-
ui.log.warn("(your login session, and every project's sync progress and agent configuration)");
|
|
77
|
+
ui.log.warn(" (your login session, and every project's sync progress and agent configuration)");
|
|
78
|
+
if (globalInstall)
|
|
79
|
+
ui.log.warn(` the ${PACKAGE_NAME} CLI itself (npm uninstall -g ${PACKAGE_NAME})`);
|
|
80
|
+
ui.log.info("Your projects and their history in the cloud are not affected.");
|
|
52
81
|
const confirmed = await confirmFn("Are you sure you want to uninstall resumecontext? This cannot be undone.");
|
|
53
82
|
if (!confirmed) {
|
|
54
83
|
ui.outro("Cancelled -- nothing was removed.");
|
|
55
84
|
return { uninstalled: false };
|
|
56
85
|
}
|
|
86
|
+
// Unregister first so no new tick starts, then end one already running,
|
|
87
|
+
// so nothing recreates the directory after it is deleted.
|
|
57
88
|
uninstallService();
|
|
89
|
+
await stopTick();
|
|
58
90
|
fs.rmSync(home, { recursive: true, force: true });
|
|
59
|
-
ui.
|
|
91
|
+
ui.log.success("Stopped auto-sync and deleted local state.");
|
|
92
|
+
if (markers.length > 0) {
|
|
93
|
+
ui.log.info(`Left in place, because they may be committed and shared with teammates (delete them if you don't want them):\n` +
|
|
94
|
+
markers.map((m) => ` ${m}`).join("\n"));
|
|
95
|
+
}
|
|
96
|
+
if (!globalInstall) {
|
|
97
|
+
ui.outro(`Uninstalled. Remove the CLI itself with the tool you installed it with, e.g. \`npm uninstall -g ${PACKAGE_NAME}\`.`);
|
|
98
|
+
return { uninstalled: true };
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await ui.withSpinner("Removing the CLI", "Removed the CLI.", async () => removePackage());
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
ui.log.warn(`Couldn't remove the CLI package. Run \`npm uninstall -g ${PACKAGE_NAME}\` yourself (it may need sudo).`);
|
|
105
|
+
ui.outro("Uninstalled, except for the CLI package.");
|
|
106
|
+
return { uninstalled: true };
|
|
107
|
+
}
|
|
108
|
+
ui.outro("Uninstalled resumecontext from this machine.");
|
|
60
109
|
return { uninstalled: true };
|
|
61
110
|
}
|
package/dist/daemon.js
CHANGED
|
@@ -42,13 +42,15 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import fs from "node:fs";
|
|
44
44
|
import path from "node:path";
|
|
45
|
+
import { execFileSync } from "node:child_process";
|
|
45
46
|
import { agentConfigFile, daemonRegistryFile, daemonLogFile, daemonLockFile, daemonFingerprintCacheFile, syncStateFile, } from "./paths.js";
|
|
46
47
|
import { apiErrorStatus } from "./apiClient.js";
|
|
47
48
|
import { readAgentConfig } from "./agentConfig.js";
|
|
48
49
|
import { readCredentials } from "./session.js";
|
|
49
50
|
import { scanForNewTurns, pushNewTurns } from "./syncCore.js";
|
|
50
51
|
import { MAX_LOG_BYTES } from "./constants.js";
|
|
51
|
-
import { installPersistentService } from "./daemonService.js";
|
|
52
|
+
import { DAEMON_SUBCOMMAND, detectPlatform, installPersistentService } from "./daemonService.js";
|
|
53
|
+
import { installLocation } from "./packageInstall.js";
|
|
52
54
|
import { fingerprintDirsForConfig } from "./localHistory/registry.js";
|
|
53
55
|
import { findProjectRoot } from "./projectRoot.js";
|
|
54
56
|
export function readRegistry() {
|
|
@@ -109,11 +111,18 @@ export function deregisterProject(projectId) {
|
|
|
109
111
|
* anything, so callers can tell the user when auto-sync isn't available
|
|
110
112
|
* here at all.
|
|
111
113
|
*
|
|
114
|
+
* Never registers from a temporary runner cache (`npx` and friends, see
|
|
115
|
+
* installLocation): the service would point at files the package manager
|
|
116
|
+
* can delete at any time, and auto-sync would then fail silently every
|
|
117
|
+
* tick. `location` is returned so callers can tell the user why.
|
|
118
|
+
*
|
|
112
119
|
* `installService` is injectable so tests can verify what would be
|
|
113
120
|
* installed -- which mechanism, which plan -- without ever registering a
|
|
114
121
|
* real OS service. */
|
|
115
|
-
export function ensureDaemonRunning(installService = installPersistentService) {
|
|
116
|
-
|
|
122
|
+
export function ensureDaemonRunning(installService = installPersistentService, location = installLocation()) {
|
|
123
|
+
if (location === "temporary")
|
|
124
|
+
return { platform: detectPlatform(), installed: false, location };
|
|
125
|
+
return { ...installService(), location };
|
|
117
126
|
}
|
|
118
127
|
// ---- log rotation -------------------------------------------------------
|
|
119
128
|
/** Keeps daemonLogFile() bounded, retaining one previous generation.
|
|
@@ -201,6 +210,67 @@ export function releaseLock(fsImpl = fs, pid = process.pid, lockPath = daemonLoc
|
|
|
201
210
|
// already gone, or unreadable -- nothing more to do
|
|
202
211
|
}
|
|
203
212
|
}
|
|
213
|
+
const realTickProcessControl = {
|
|
214
|
+
isAlive: isProcessAlive,
|
|
215
|
+
commandLine(pid) {
|
|
216
|
+
try {
|
|
217
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
218
|
+
encoding: "utf8",
|
|
219
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
220
|
+
});
|
|
221
|
+
return out.trim() || null;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
signal(pid, signal) {
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, signal);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// already gone
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
/** Ends the tick that holds the lock right now, if there is one. Used by
|
|
237
|
+
* uninstall: unregistering the schedule stops FUTURE ticks, but a tick
|
|
238
|
+
* already mid-scan would otherwise carry on and recreate sync state, the
|
|
239
|
+
* log and the lock inside a home directory uninstall just deleted.
|
|
240
|
+
*
|
|
241
|
+
* The lock only records a pid, and pids get reused, so the process is
|
|
242
|
+
* signalled only if its command line is still a daemon tick. SIGTERM
|
|
243
|
+
* first; SIGKILL if it hasn't exited by `timeoutMs`. Every push is
|
|
244
|
+
* idempotent on the server, so cutting one short loses nothing. Returns
|
|
245
|
+
* whether a running tick was found. */
|
|
246
|
+
export async function stopRunningTick(control = realTickProcessControl, lockPath = daemonLockFile(), timeoutMs = 5_000) {
|
|
247
|
+
let pid;
|
|
248
|
+
try {
|
|
249
|
+
pid = Number(fs.readFileSync(lockPath, "utf8"));
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return false; // no lock: nothing is running
|
|
253
|
+
}
|
|
254
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid)
|
|
255
|
+
return false;
|
|
256
|
+
if (!control.isAlive(pid))
|
|
257
|
+
return false;
|
|
258
|
+
if (!control.commandLine(pid)?.includes(DAEMON_SUBCOMMAND))
|
|
259
|
+
return false;
|
|
260
|
+
const waitForExit = async (ms) => {
|
|
261
|
+
const deadline = Date.now() + ms;
|
|
262
|
+
while (control.isAlive(pid) && Date.now() < deadline) {
|
|
263
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
control.signal(pid, "SIGTERM");
|
|
267
|
+
await waitForExit(timeoutMs);
|
|
268
|
+
if (control.isAlive(pid)) {
|
|
269
|
+
control.signal(pid, "SIGKILL");
|
|
270
|
+
await waitForExit(1_000);
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
204
274
|
// ---- change detection -----------------------------------------------------
|
|
205
275
|
/** SQLite's WAL-mode sidecar files -- `<db>-shm` (shared memory index) and
|
|
206
276
|
* `<db>-wal` (write-ahead log). Excluded from the fingerprint below: opening
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as ui from "./ui.js";
|
|
2
|
+
export function printDaemonNotice(result) {
|
|
3
|
+
if (result.location === "temporary") {
|
|
4
|
+
ui.log.warn("Auto-sync is not set up: this CLI is running from a temporary download (npx) that can be deleted at any time.\n" +
|
|
5
|
+
"Install it with `npm install -g resumecontext` to sync in the background. `resumecontext sync` still works.");
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
if (result.platform === "unsupported") {
|
|
9
|
+
ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
// Only when (re)registered, which is the first run and after an update --
|
|
13
|
+
// not on every command.
|
|
14
|
+
if (result.location === "folder" && result.installed) {
|
|
15
|
+
ui.log.warn("Auto-sync is running from this folder's node_modules and stops working if that folder or its node_modules is removed.\n" +
|
|
16
|
+
"Install globally with `npm install -g resumecontext` so it doesn't depend on a folder.");
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/daemonService.js
CHANGED
|
@@ -166,6 +166,9 @@ ${envBlock} <key>RunAtLoad</key>
|
|
|
166
166
|
statusCommand: ["launchctl", "list", LAUNCHD_LABEL],
|
|
167
167
|
installCommands: [["launchctl", "load", "-w", plistPath]],
|
|
168
168
|
uninstallCommands: [["launchctl", "unload", "-w", plistPath]],
|
|
169
|
+
// `launchctl unload` already sends SIGTERM to a tick that is running.
|
|
170
|
+
stopRunningCommands: [],
|
|
171
|
+
afterRemoveCommands: [],
|
|
169
172
|
};
|
|
170
173
|
}
|
|
171
174
|
/** Same `version` parameter and reasoning as buildLaunchdPlan above. */
|
|
@@ -219,6 +222,9 @@ WantedBy=timers.target
|
|
|
219
222
|
["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT],
|
|
220
223
|
],
|
|
221
224
|
uninstallCommands: [["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT]],
|
|
225
|
+
// Stopping the timer does not stop a oneshot run it already started.
|
|
226
|
+
stopRunningCommands: [["systemctl", "--user", "stop", SYSTEMD_SERVICE_UNIT]],
|
|
227
|
+
afterRemoveCommands: [["systemctl", "--user", "daemon-reload"]],
|
|
222
228
|
};
|
|
223
229
|
}
|
|
224
230
|
export function buildPlan(platform, homeDir, version) {
|
|
@@ -307,12 +313,12 @@ export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsIm
|
|
|
307
313
|
if (!plan)
|
|
308
314
|
return { platform, uninstalled: false };
|
|
309
315
|
const wasActive = isServiceActive(execImpl, homeDir);
|
|
310
|
-
for (const [cmd, ...args] of plan.uninstallCommands) {
|
|
316
|
+
for (const [cmd, ...args] of [...plan.uninstallCommands, ...plan.stopRunningCommands]) {
|
|
311
317
|
try {
|
|
312
318
|
execImpl(cmd, args);
|
|
313
319
|
}
|
|
314
320
|
catch {
|
|
315
|
-
// not currently loaded/enabled -- nothing to
|
|
321
|
+
// not currently loaded/enabled/running -- nothing to stop
|
|
316
322
|
}
|
|
317
323
|
}
|
|
318
324
|
let removedFiles = false;
|
|
@@ -322,5 +328,15 @@ export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsIm
|
|
|
322
328
|
removedFiles = true;
|
|
323
329
|
}
|
|
324
330
|
}
|
|
331
|
+
if (removedFiles) {
|
|
332
|
+
for (const [cmd, ...args] of plan.afterRemoveCommands) {
|
|
333
|
+
try {
|
|
334
|
+
execImpl(cmd, args);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
// best effort -- the files are already gone
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
325
341
|
return { platform, uninstalled: wasActive || removedFiles };
|
|
326
342
|
}
|
package/dist/index.js
CHANGED
|
@@ -126,7 +126,7 @@ projectsCmd
|
|
|
126
126
|
.action(withErrorHandling((projectId) => runProjectsDelete(deps(), projectId)));
|
|
127
127
|
program
|
|
128
128
|
.command("uninstall")
|
|
129
|
-
.description("
|
|
129
|
+
.description("remove the CLI, its auto-sync and all local state from this machine (asks to confirm)")
|
|
130
130
|
.action(withErrorHandling(() => runUninstall()));
|
|
131
131
|
// Hidden: not something a user runs directly. This is the entry point the
|
|
132
132
|
// OS scheduler invokes for EVERY tick -- the launchd plist's
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this CLI itself was installed, so `uninstall` can remove the package
|
|
3
|
+
* as well as its state.
|
|
4
|
+
*
|
|
5
|
+
* Only a global npm install is removed automatically: the running script has
|
|
6
|
+
* to live inside what `npm root -g` holds for this package (for an `npm link`
|
|
7
|
+
* that is the link, and uninstalling removes only the link). Anything else --
|
|
8
|
+
* a dev checkout under tsx, npx's cache, another package manager -- is left
|
|
9
|
+
* alone, because `npm uninstall -g` there would do nothing or remove
|
|
10
|
+
* something this process isn't.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
export const PACKAGE_NAME = "resumecontext";
|
|
16
|
+
function defaultRun(command, args) {
|
|
17
|
+
return execFileSync(command, args, {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
// npm is npm.cmd on Windows, which execFile can't start without a shell.
|
|
21
|
+
shell: process.platform === "win32",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function realpathOrNull(p) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.realpathSync(p);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** True if the running CLI is the copy installed by `npm install -g`. */
|
|
33
|
+
export function isGlobalNpmInstall(run = defaultRun, entry = process.argv[1]) {
|
|
34
|
+
if (!entry)
|
|
35
|
+
return false;
|
|
36
|
+
const script = realpathOrNull(entry);
|
|
37
|
+
if (!script)
|
|
38
|
+
return false;
|
|
39
|
+
let root;
|
|
40
|
+
try {
|
|
41
|
+
root = run("npm", ["root", "-g"]).trim();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false; // no npm on PATH
|
|
45
|
+
}
|
|
46
|
+
const packageDir = realpathOrNull(path.join(root, PACKAGE_NAME));
|
|
47
|
+
if (packageDir !== null && script.startsWith(packageDir + path.sep))
|
|
48
|
+
return true;
|
|
49
|
+
// npm masks anything in its output that looks like a secret, including a
|
|
50
|
+
// UUID-shaped directory name ("/Users/me/***/lib/node_modules"), which no
|
|
51
|
+
// longer exists on disk. Match the masked root against where the running
|
|
52
|
+
// package actually sits, with each `***` standing for one path segment.
|
|
53
|
+
if (!root.includes("***"))
|
|
54
|
+
return false;
|
|
55
|
+
// The part before the first mask does exist, so resolve its symlinks the
|
|
56
|
+
// way the script's path was resolved (macOS: /var -> /private/var).
|
|
57
|
+
const [head, ...rest] = path.join(root, PACKAGE_NAME).split("***");
|
|
58
|
+
const headDir = realpathOrNull(path.dirname(head + "x"));
|
|
59
|
+
if (headDir === null)
|
|
60
|
+
return false;
|
|
61
|
+
const parts = [headDir + head.slice(path.dirname(head + "x").length), ...rest];
|
|
62
|
+
const pattern = new RegExp("^" + parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[^/\\\\]+") + "$");
|
|
63
|
+
for (let dir = path.dirname(script); dir !== path.dirname(dir); dir = path.dirname(dir)) {
|
|
64
|
+
if (path.basename(dir) === PACKAGE_NAME)
|
|
65
|
+
return pattern.test(dir);
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/** `npm uninstall -g resumecontext`. Throws if npm fails (most often a
|
|
70
|
+
* global prefix that needs sudo). Safe while this process is running: every
|
|
71
|
+
* module is already loaded. */
|
|
72
|
+
export function removeGlobalNpmPackage(run = defaultRun) {
|
|
73
|
+
run("npm", ["uninstall", "-g", PACKAGE_NAME]);
|
|
74
|
+
}
|
|
75
|
+
export function installLocation(entry = process.argv[1]) {
|
|
76
|
+
if (!entry)
|
|
77
|
+
return "other";
|
|
78
|
+
const paths = [entry, realpathOrNull(entry) ?? entry].map((p) => p.split(path.sep).join("/"));
|
|
79
|
+
if (paths.some((p) => /\/_npx\/|\/dlx[-/]|\/bunx-/.test(p)))
|
|
80
|
+
return "temporary";
|
|
81
|
+
// A folder install's bin is always node_modules/.bin (npm, yarn, bun);
|
|
82
|
+
// pnpm's bin script instead runs the real file inside node_modules/.pnpm.
|
|
83
|
+
// Global installs use neither.
|
|
84
|
+
if (paths.some((p) => p.includes("/node_modules/.bin/") || p.includes("/node_modules/.pnpm/")))
|
|
85
|
+
return "folder";
|
|
86
|
+
return "other";
|
|
87
|
+
}
|