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.
@@ -17,13 +17,7 @@
17
17
  */
18
18
  import { ensureAgentConfig } from "./agentConfig.js";
19
19
  import { registerProject, ensureDaemonRunning } from "./daemon.js";
20
- import * as ui from "./ui.js";
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
- warnIfDaemonUnsupported(ensureDaemonRunningFn);
34
+ printDaemonNotice(ensureDaemonRunningFn());
41
35
  return config;
42
36
  }
@@ -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
- const { platform } = ensureDaemonRunningFn();
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;
@@ -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, installPersistentService, uninstallPersistentService, detectPlatform } from "../daemonService.js";
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
- if (platform === "unsupported") {
82
- ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
83
- ui.outro("Not available.");
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 } = installPersistentService();
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` -- stops the background auto-sync service and
3
- * deletes every bit of local resumecontext state: credentials, every
4
- * project's sync progress and agent configuration, the daemon's project
5
- * registry and log. This is the one command in the whole CLI that can't
6
- * be undone by running another command afterward, so it asks for
7
- * confirmation first, defaulting to No.
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.outro("Uninstalled. Run `resumecontext auth` any time to start fresh.");
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
- return installService();
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
+ }
@@ -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 unload
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("stop auto-sync and delete all local resumecontext state (asks to confirm)")
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resumecontext",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Sync your coding-agent sessions to a shared archive your agents can search over MCP.",
5
5
  "keywords": [
6
6
  "mcp",