resumecontext 0.1.3 → 0.1.4
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/commands/daemon.js +1 -1
- package/dist/daemon.js +10 -4
- package/dist/daemonNotices.js +9 -0
- package/dist/daemonService.js +102 -17
- package/package.json +1 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -81,7 +81,7 @@ export async function runDaemonStart(ensureDaemonRunningFn = ensureDaemonRunning
|
|
|
81
81
|
const platform = detectPlatform();
|
|
82
82
|
const result = platform === "unsupported" ? { platform, installed: false } : ensureDaemonRunningFn();
|
|
83
83
|
printDaemonNotice(result);
|
|
84
|
-
if (result.platform === "unsupported" || result.location === "temporary") {
|
|
84
|
+
if (result.platform === "unsupported" || result.location === "temporary" || result.error) {
|
|
85
85
|
ui.outro(result.platform === "unsupported" ? "Not available." : "Not started.");
|
|
86
86
|
return { started: false };
|
|
87
87
|
}
|
package/dist/daemon.js
CHANGED
|
@@ -49,7 +49,7 @@ import { readAgentConfig } from "./agentConfig.js";
|
|
|
49
49
|
import { readCredentials } from "./session.js";
|
|
50
50
|
import { scanForNewTurns, pushNewTurns } from "./syncCore.js";
|
|
51
51
|
import { MAX_LOG_BYTES } from "./constants.js";
|
|
52
|
-
import { DAEMON_SUBCOMMAND, detectPlatform, installPersistentService } from "./daemonService.js";
|
|
52
|
+
import { DAEMON_SUBCOMMAND, detectPlatform, installPersistentService, stopsAtLogout } from "./daemonService.js";
|
|
53
53
|
import { installLocation } from "./packageInstall.js";
|
|
54
54
|
import { fingerprintDirsForConfig } from "./localHistory/registry.js";
|
|
55
55
|
import { findProjectRoot } from "./projectRoot.js";
|
|
@@ -98,7 +98,6 @@ export function deregisterProject(projectId) {
|
|
|
98
98
|
fs.rmSync(agentConfigFile(projectId), { force: true });
|
|
99
99
|
fs.rmSync(syncStateFile(projectId), { force: true });
|
|
100
100
|
}
|
|
101
|
-
// ---- lifecycle: making sure auto-sync is scheduled with the OS --------
|
|
102
101
|
/** Registers auto-sync as a real OS-managed schedule if this platform
|
|
103
102
|
* supports one (see daemonService.ts) -- no fallback of any kind on a
|
|
104
103
|
* platform with neither launchd nor systemd, so what's registered is
|
|
@@ -119,10 +118,17 @@ export function deregisterProject(projectId) {
|
|
|
119
118
|
* `installService` is injectable so tests can verify what would be
|
|
120
119
|
* installed -- which mechanism, which plan -- without ever registering a
|
|
121
120
|
* real OS service. */
|
|
122
|
-
export function ensureDaemonRunning(installService = installPersistentService, location = installLocation()) {
|
|
121
|
+
export function ensureDaemonRunning(installService = installPersistentService, location = installLocation(), checkStopsAtLogout = stopsAtLogout) {
|
|
123
122
|
if (location === "temporary")
|
|
124
123
|
return { platform: detectPlatform(), installed: false, location };
|
|
125
|
-
|
|
124
|
+
try {
|
|
125
|
+
const result = { ...installService(), location };
|
|
126
|
+
// Only on a fresh registration, so this runs once, not on every command.
|
|
127
|
+
return result.installed && checkStopsAtLogout() ? { ...result, stopsAtLogout: true } : result;
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return { platform: detectPlatform(), installed: false, location, error: err instanceof Error ? err.message : String(err) };
|
|
131
|
+
}
|
|
126
132
|
}
|
|
127
133
|
// ---- log rotation -------------------------------------------------------
|
|
128
134
|
/** Keeps daemonLogFile() bounded, retaining one previous generation.
|
package/dist/daemonNotices.js
CHANGED
|
@@ -5,10 +5,19 @@ export function printDaemonNotice(result) {
|
|
|
5
5
|
"Install it with `npm install -g resumecontext` to sync in the background. `resumecontext sync` still works.");
|
|
6
6
|
return;
|
|
7
7
|
}
|
|
8
|
+
if (result.error) {
|
|
9
|
+
ui.log.warn(`Auto-sync couldn't be set up: ${result.error}\n` +
|
|
10
|
+
"Everything else works, and `resumecontext sync` syncs by hand. Run `resumecontext daemon start` to retry.");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
8
13
|
if (result.platform === "unsupported") {
|
|
9
14
|
ui.log.warn("Auto-sync isn't available on this OS (no launchd or systemd found) -- run `resumecontext sync` manually.");
|
|
10
15
|
return;
|
|
11
16
|
}
|
|
17
|
+
if (result.stopsAtLogout) {
|
|
18
|
+
ui.log.warn("Auto-sync will stop when you log out of this machine. To keep it running (for example on a server), run:\n" +
|
|
19
|
+
" sudo loginctl enable-linger $USER");
|
|
20
|
+
}
|
|
12
21
|
// Only when (re)registered, which is the first run and after an update --
|
|
13
22
|
// not on every command.
|
|
14
23
|
if (result.location === "folder" && result.installed) {
|
package/dist/daemonService.js
CHANGED
|
@@ -171,16 +171,23 @@ ${envBlock} <key>RunAtLoad</key>
|
|
|
171
171
|
afterRemoveCommands: [],
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
|
+
export function systemdScope() {
|
|
175
|
+
return process.getuid?.() === 0 ? "system" : "user";
|
|
176
|
+
}
|
|
174
177
|
/** Same `version` parameter and reasoning as buildLaunchdPlan above. */
|
|
175
|
-
export function buildSystemdPlan(homeDir = os.homedir(), version = cliVersion()) {
|
|
178
|
+
export function buildSystemdPlan(homeDir = os.homedir(), version = cliVersion(), scope = "user") {
|
|
176
179
|
const args = daemonProgramArguments();
|
|
177
180
|
const logFile = daemonLogFile();
|
|
178
181
|
const { systemdService: SYSTEMD_SERVICE_UNIT, systemdTimer: SYSTEMD_TIMER_UNIT } = serviceNames();
|
|
179
|
-
const
|
|
180
|
-
const servicePath = path.join(
|
|
181
|
-
const timerPath = path.join(
|
|
182
|
+
const unitDir = scope === "system" ? "/etc/systemd/system" : path.join(homeDir, ".config", "systemd", "user");
|
|
183
|
+
const servicePath = path.join(unitDir, SYSTEMD_SERVICE_UNIT);
|
|
184
|
+
const timerPath = path.join(unitDir, SYSTEMD_TIMER_UNIT);
|
|
182
185
|
const quote = (s) => (/\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
|
|
183
|
-
const
|
|
186
|
+
const systemctl = scope === "system" ? ["systemctl"] : ["systemctl", "--user"];
|
|
187
|
+
// A system service gets no $HOME of its own; set it, so every "~" the
|
|
188
|
+
// CLI resolves (its state, each agent's history) is the installing user's.
|
|
189
|
+
const env = scope === "system" ? { HOME: homeDir, ...daemonEnvironment() } : daemonEnvironment();
|
|
190
|
+
const envLines = Object.entries(env)
|
|
184
191
|
.map(([k, v]) => `Environment=${k}=${quote(v)}\n`)
|
|
185
192
|
.join("");
|
|
186
193
|
const serviceContents = `# resumecontext-cli-version: ${version}
|
|
@@ -196,13 +203,16 @@ StandardError=append:${logFile}
|
|
|
196
203
|
// OnBootSec=0 fires the first tick immediately once the timer starts
|
|
197
204
|
// (mirrors launchd's RunAtLoad); OnUnitActiveSec counts from when the
|
|
198
205
|
// triggered service last finished, not from a fixed clock, so a slow
|
|
199
|
-
// tick doesn't cause the next one to fire early or overlap.
|
|
206
|
+
// tick doesn't cause the next one to fire early or overlap. AccuracySec:
|
|
207
|
+
// systemd otherwise lets a timer fire anywhere in a 1-minute window, which
|
|
208
|
+
// turned a 20s interval into 20-60s.
|
|
200
209
|
const timerContents = `[Unit]
|
|
201
210
|
Description=resumecontext auto-sync timer
|
|
202
211
|
|
|
203
212
|
[Timer]
|
|
204
213
|
OnBootSec=0
|
|
205
214
|
OnUnitActiveSec=${DAEMON_INTERVAL_SECONDS}s
|
|
215
|
+
AccuracySec=1s
|
|
206
216
|
Unit=${SYSTEMD_SERVICE_UNIT}
|
|
207
217
|
|
|
208
218
|
[Install]
|
|
@@ -214,28 +224,55 @@ WantedBy=timers.target
|
|
|
214
224
|
{ path: timerPath, contents: timerContents },
|
|
215
225
|
],
|
|
216
226
|
// Checks the TIMER, not the oneshot service -- see ServicePlan's doc.
|
|
217
|
-
statusCommand: [
|
|
227
|
+
statusCommand: [...systemctl, "is-active", "--quiet", SYSTEMD_TIMER_UNIT],
|
|
218
228
|
// Enabling/starting the TIMER is what schedules the service; the
|
|
219
229
|
// service unit itself is never enabled or started directly.
|
|
220
230
|
installCommands: [
|
|
221
|
-
[
|
|
222
|
-
[
|
|
231
|
+
[...systemctl, "daemon-reload"],
|
|
232
|
+
[...systemctl, "enable", "--now", SYSTEMD_TIMER_UNIT],
|
|
223
233
|
],
|
|
224
|
-
uninstallCommands: [[
|
|
234
|
+
uninstallCommands: [[...systemctl, "disable", "--now", SYSTEMD_TIMER_UNIT]],
|
|
225
235
|
// Stopping the timer does not stop a oneshot run it already started.
|
|
226
|
-
stopRunningCommands: [[
|
|
227
|
-
afterRemoveCommands: [[
|
|
236
|
+
stopRunningCommands: [[...systemctl, "stop", SYSTEMD_SERVICE_UNIT]],
|
|
237
|
+
afterRemoveCommands: [[...systemctl, "daemon-reload"]],
|
|
228
238
|
};
|
|
229
239
|
}
|
|
230
|
-
export function buildPlan(platform, homeDir, version) {
|
|
240
|
+
export function buildPlan(platform, homeDir, version, scope = systemdScope()) {
|
|
231
241
|
if (platform === "launchd")
|
|
232
242
|
return buildLaunchdPlan(homeDir, version);
|
|
233
243
|
if (platform === "systemd")
|
|
234
|
-
return buildSystemdPlan(homeDir, version);
|
|
244
|
+
return buildSystemdPlan(homeDir, version, scope);
|
|
235
245
|
return null;
|
|
236
246
|
}
|
|
237
|
-
function defaultExec(command, args) {
|
|
238
|
-
|
|
247
|
+
export function defaultExec(command, args) {
|
|
248
|
+
try {
|
|
249
|
+
execFileSync(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
if (err.code === "ENOENT")
|
|
253
|
+
throw new Error(`\`${command}\` was not found on this machine`);
|
|
254
|
+
const stderr = String(err.stderr ?? "").trim();
|
|
255
|
+
throw new Error(`\`${[command, ...args].join(" ")}\` failed${stderr ? `: ${stderr}` : ""}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* True when a user-scope systemd timer will stop the moment this user logs
|
|
260
|
+
* out -- the normal state for an SSH account on a server, and invisible
|
|
261
|
+
* until syncing quietly stops. False when lingering is on, for system scope,
|
|
262
|
+
* or when it can't be determined.
|
|
263
|
+
*/
|
|
264
|
+
export function stopsAtLogout(run = defaultRun) {
|
|
265
|
+
if (detectPlatform() !== "systemd" || systemdScope() !== "user")
|
|
266
|
+
return false;
|
|
267
|
+
try {
|
|
268
|
+
return run("loginctl", ["show-user", os.userInfo().username, "--property=Linger", "--value"]).trim() === "no";
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function defaultRun(command, args) {
|
|
275
|
+
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
239
276
|
}
|
|
240
277
|
/** True if `plan`'s schedule is already registered/active. */
|
|
241
278
|
export function isServiceActive(execImpl = defaultExec, homeDir) {
|
|
@@ -250,6 +287,46 @@ export function isServiceActive(execImpl = defaultExec, homeDir) {
|
|
|
250
287
|
return false;
|
|
251
288
|
}
|
|
252
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Registrations an earlier version may have left that the current plan
|
|
292
|
+
* replaces. Today that is one case: root on systemd used to get a USER timer
|
|
293
|
+
* (~/.config/systemd/user) and now gets a system one. Left in place, it would
|
|
294
|
+
* keep firing a second timer -- and survive `daemon stop` and `uninstall`,
|
|
295
|
+
* which only know the current plan.
|
|
296
|
+
*/
|
|
297
|
+
function supersededPlans(platform, homeDir) {
|
|
298
|
+
return platform === "systemd" && systemdScope() === "system" ? [buildSystemdPlan(homeDir, undefined, "user")] : [];
|
|
299
|
+
}
|
|
300
|
+
/** Best effort, and a no-op unless one of their files is actually on disk --
|
|
301
|
+
* so the common case runs no commands at all. */
|
|
302
|
+
function removeSupersededPlans(platform, homeDir, execImpl, fsImpl) {
|
|
303
|
+
let removed = false;
|
|
304
|
+
for (const plan of supersededPlans(platform, homeDir)) {
|
|
305
|
+
const present = plan.files.filter((f) => fsImpl.existsSync(f.path));
|
|
306
|
+
if (present.length === 0)
|
|
307
|
+
continue;
|
|
308
|
+
for (const [cmd, ...args] of [...plan.uninstallCommands, ...plan.stopRunningCommands]) {
|
|
309
|
+
try {
|
|
310
|
+
execImpl(cmd, args);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// that manager may not even be reachable -- removing the files is what matters
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const file of present)
|
|
317
|
+
fsImpl.unlinkSync(file.path);
|
|
318
|
+
for (const [cmd, ...args] of plan.afterRemoveCommands) {
|
|
319
|
+
try {
|
|
320
|
+
execImpl(cmd, args);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
// best effort
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
removed = true;
|
|
327
|
+
}
|
|
328
|
+
return removed;
|
|
329
|
+
}
|
|
253
330
|
function readFileIfPresent(fsImpl, filePath) {
|
|
254
331
|
try {
|
|
255
332
|
return fsImpl.readFileSync(filePath, "utf8");
|
|
@@ -270,6 +347,13 @@ export function installPersistentService(fsImpl = fs, execImpl = defaultExec, ho
|
|
|
270
347
|
const plan = buildPlan(platform, homeDir);
|
|
271
348
|
if (!plan)
|
|
272
349
|
return { platform, installed: false };
|
|
350
|
+
// systemd refuses to start a unit whose StandardOutput=append: file sits
|
|
351
|
+
// in a directory that doesn't exist ("Failed to set up standard output",
|
|
352
|
+
// status 209) -- and nothing else creates it until a project is added.
|
|
353
|
+
// Created on every call, before the early return, so a deleted directory
|
|
354
|
+
// is repaired by the next command too.
|
|
355
|
+
fsImpl.mkdirSync(path.dirname(daemonLogFile()), { recursive: true });
|
|
356
|
+
removeSupersededPlans(platform, homeDir, execImpl, fsImpl);
|
|
273
357
|
// "Already active" is NOT enough to skip: what's registered could be
|
|
274
358
|
// running the WRONG program, or an OUTDATED one. Two distinct cases,
|
|
275
359
|
// both covered by the same plain string comparison:
|
|
@@ -313,6 +397,7 @@ export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsIm
|
|
|
313
397
|
if (!plan)
|
|
314
398
|
return { platform, uninstalled: false };
|
|
315
399
|
const wasActive = isServiceActive(execImpl, homeDir);
|
|
400
|
+
const removedSuperseded = removeSupersededPlans(platform, homeDir, execImpl, fsImpl);
|
|
316
401
|
for (const [cmd, ...args] of [...plan.uninstallCommands, ...plan.stopRunningCommands]) {
|
|
317
402
|
try {
|
|
318
403
|
execImpl(cmd, args);
|
|
@@ -338,5 +423,5 @@ export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsIm
|
|
|
338
423
|
}
|
|
339
424
|
}
|
|
340
425
|
}
|
|
341
|
-
return { platform, uninstalled: wasActive || removedFiles };
|
|
426
|
+
return { platform, uninstalled: wasActive || removedFiles || removedSuperseded };
|
|
342
427
|
}
|