resumecontext 0.1.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 +27 -0
- package/dist/agentConfig.js +202 -0
- package/dist/agentConfigWithDaemon.js +42 -0
- package/dist/apiClient.js +54 -0
- package/dist/browser.js +20 -0
- package/dist/cloudApi.js +15 -0
- package/dist/commands/accept.js +20 -0
- package/dist/commands/agents.js +35 -0
- package/dist/commands/auth.js +55 -0
- package/dist/commands/daemon.js +99 -0
- package/dist/commands/init.js +59 -0
- package/dist/commands/logout.js +20 -0
- package/dist/commands/mcp.js +54 -0
- package/dist/commands/members.js +20 -0
- package/dist/commands/projects.js +55 -0
- package/dist/commands/revoke.js +15 -0
- package/dist/commands/share.js +18 -0
- package/dist/commands/sync.js +59 -0
- package/dist/commands/uninstall.js +61 -0
- package/dist/constants.js +61 -0
- package/dist/daemon.js +409 -0
- package/dist/daemonService.js +326 -0
- package/dist/deps.js +1 -0
- package/dist/dev.js +32 -0
- package/dist/device.js +40 -0
- package/dist/httpCloudApi.js +61 -0
- package/dist/index.js +160 -0
- package/dist/localCapture.js +18 -0
- package/dist/localHistory/claudeCode.js +82 -0
- package/dist/localHistory/codex.js +106 -0
- package/dist/localHistory/cursor.js +492 -0
- package/dist/localHistory/index.js +96 -0
- package/dist/localHistory/opencode.js +148 -0
- package/dist/localHistory/registry.js +66 -0
- package/dist/localHistory/shared.js +174 -0
- package/dist/paths.js +85 -0
- package/dist/projectRoot.js +77 -0
- package/dist/session.js +36 -0
- package/dist/syncCore.js +108 -0
- package/dist/syncState.js +51 -0
- package/dist/ui.js +289 -0
- package/dist/utils.js +41 -0
- package/dist/version.js +43 -0
- package/package.json +64 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers auto-sync as a real OS-scheduled mechanism -- launchd
|
|
3
|
+
* StartInterval on macOS, a systemd --user timer on Linux -- so it survives
|
|
4
|
+
* a reboot and keeps firing on schedule, rather than being just a detached
|
|
5
|
+
* subprocess that a `kill -9`, a log-out, or a restart silently ends for
|
|
6
|
+
* good.
|
|
7
|
+
*
|
|
8
|
+
* Each platform's mechanism triggers a fresh, short-lived process per tick
|
|
9
|
+
* (daemon.ts's runDaemonTick) instead of supervising one long-running
|
|
10
|
+
* process -- see daemon.ts's module doc comment for why that's the design.
|
|
11
|
+
* launchd does this natively via StartInterval (RunAtLoad fires the first
|
|
12
|
+
* tick immediately, StartInterval fires every one after). systemd has no
|
|
13
|
+
* exact equivalent on the service unit itself, so Linux uses a `.timer`
|
|
14
|
+
* unit (the schedule) paired with a `Type=oneshot` `.service` unit (the
|
|
15
|
+
* work) -- the timer is what gets enabled/started; the service is what the
|
|
16
|
+
* timer activates each time. Both platforms end up with the identical
|
|
17
|
+
* shape from daemon.ts's point of view: something re-invokes this CLI with
|
|
18
|
+
* the hidden `__daemon-run` subcommand every DAEMON_INTERVAL_MS.
|
|
19
|
+
*
|
|
20
|
+
* Every OS-touching function here is split into a pure "build the plan"
|
|
21
|
+
* half (the config file contents and the commands to run, computed with no
|
|
22
|
+
* I/O) and a thin "apply it" half that actually writes files and runs
|
|
23
|
+
* commands -- with fs/exec injectable on the apply half specifically so
|
|
24
|
+
* tests can verify the exact plan produced without ever writing to a real
|
|
25
|
+
* user's ~/Library/LaunchAgents or ~/.config/systemd, or running a real
|
|
26
|
+
* launchctl/systemctl command. Nothing in this file's test suite is
|
|
27
|
+
* permitted to touch the real OS service manager -- see daemonService.test.ts.
|
|
28
|
+
*/
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
33
|
+
import crypto from "node:crypto";
|
|
34
|
+
import { DAEMON_INTERVAL_MS } from "./constants.js";
|
|
35
|
+
import { daemonLogFile } from "./paths.js";
|
|
36
|
+
import { cliVersion } from "./version.js";
|
|
37
|
+
export const DAEMON_SUBCOMMAND = "__daemon-run";
|
|
38
|
+
const DAEMON_INTERVAL_SECONDS = DAEMON_INTERVAL_MS / 1000;
|
|
39
|
+
/** Re-invokes this exact process as one daemon tick: same interpreter,
|
|
40
|
+
* same loader flags (tsx's --require/--import hooks in dev, none once this
|
|
41
|
+
* is the compiled dist/index.js), same entry script, plus the hidden
|
|
42
|
+
* daemon subcommand -- so both the launchd/systemd plans below agree on
|
|
43
|
+
* exactly how to relaunch themselves. */
|
|
44
|
+
export function daemonProgramArguments() {
|
|
45
|
+
return [process.execPath, ...process.execArgv, process.argv[1], DAEMON_SUBCOMMAND];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The environment overrides the service has to carry.
|
|
49
|
+
*
|
|
50
|
+
* Neither launchd nor systemd inherits the shell that installed the service,
|
|
51
|
+
* so without this a daemon registered by a CLI pointed at a local or staging
|
|
52
|
+
* backend would wake up pointed at production, and push that project's
|
|
53
|
+
* sessions there. These two are the only variables that change which backend
|
|
54
|
+
* is talked to and which credentials are used, so they are the only two worth
|
|
55
|
+
* baking in.
|
|
56
|
+
*
|
|
57
|
+
* Empty for an ordinary install, which is the point: the service file for
|
|
58
|
+
* someone who has never set either of these is byte-for-byte what it always
|
|
59
|
+
* was. And because installPersistentService compares file contents, switching
|
|
60
|
+
* between backends rewrites the file and forces a reinstall rather than
|
|
61
|
+
* leaving a daemon quietly pointed at the old one.
|
|
62
|
+
*/
|
|
63
|
+
export function daemonEnvironment() {
|
|
64
|
+
const env = {};
|
|
65
|
+
for (const key of ["RESUMECONTEXT_API_URL", "RESUMECONTEXT_HOME"]) {
|
|
66
|
+
const value = process.env[key];
|
|
67
|
+
if (value)
|
|
68
|
+
env[key] = value;
|
|
69
|
+
}
|
|
70
|
+
return env;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The launchd label and systemd unit names for THIS configuration.
|
|
74
|
+
*
|
|
75
|
+
* An ordinary install gets the plain names. A CLI running with either override
|
|
76
|
+
* (the dev CLI, pointed at a local backend with its own state directory) gets
|
|
77
|
+
* names suffixed with a hash of those overrides. Sharing one name meant signing
|
|
78
|
+
* in with the dev CLI rewrote the real CLI's service file -- pointing the real
|
|
79
|
+
* daemon at localhost and a different state directory, so real projects
|
|
80
|
+
* stopped syncing until the real CLI happened to reinstall it, which in turn
|
|
81
|
+
* silently unregistered the dev one.
|
|
82
|
+
*/
|
|
83
|
+
export function serviceNames() {
|
|
84
|
+
const env = daemonEnvironment();
|
|
85
|
+
const suffix = Object.keys(env).length === 0
|
|
86
|
+
? ""
|
|
87
|
+
: `-${crypto.createHash("sha256").update(JSON.stringify(Object.entries(env).sort())).digest("hex").slice(0, 8)}`;
|
|
88
|
+
return {
|
|
89
|
+
launchdLabel: `com.resumecontext.daemon${suffix.replace("-", ".")}`,
|
|
90
|
+
systemdService: `resumecontext-daemon${suffix}.service`,
|
|
91
|
+
systemdTimer: `resumecontext-daemon${suffix}.timer`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function detectPlatform() {
|
|
95
|
+
if (process.platform === "darwin")
|
|
96
|
+
return "launchd";
|
|
97
|
+
if (process.platform === "linux")
|
|
98
|
+
return "systemd";
|
|
99
|
+
return "unsupported";
|
|
100
|
+
}
|
|
101
|
+
function xmlEscape(s) {
|
|
102
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
103
|
+
}
|
|
104
|
+
/** `homeDir` defaults to the real home directory but is a parameter (not
|
|
105
|
+
* baked in) specifically so tests can point every path this produces at a
|
|
106
|
+
* scratch directory, as a second line of defense on top of fs injection --
|
|
107
|
+
* even a test that slipped and used the real fs would land somewhere
|
|
108
|
+
* harmless.
|
|
109
|
+
*
|
|
110
|
+
* `version` defaults to cliVersion() but is likewise a parameter so tests
|
|
111
|
+
* can pin it -- it's embedded below purely as a comment so that a version
|
|
112
|
+
* bump changes the file's contents even when daemonProgramArguments()
|
|
113
|
+
* doesn't (an in-place reinstall at the same path, e.g. `npm install -g`
|
|
114
|
+
* over an existing global install). installPersistentService's staleness
|
|
115
|
+
* check is a plain string comparison of every file's contents, so this is
|
|
116
|
+
* enough to make every version bump force a reinstall -- see that
|
|
117
|
+
* function's doc comment. */
|
|
118
|
+
export function buildLaunchdPlan(homeDir = os.homedir(), version = cliVersion()) {
|
|
119
|
+
const args = daemonProgramArguments();
|
|
120
|
+
const logFile = daemonLogFile();
|
|
121
|
+
const { launchdLabel: LAUNCHD_LABEL } = serviceNames();
|
|
122
|
+
const plistPath = path.join(homeDir, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
123
|
+
const env = daemonEnvironment();
|
|
124
|
+
const envBlock = Object.keys(env).length === 0
|
|
125
|
+
? ""
|
|
126
|
+
: ` <key>EnvironmentVariables</key>
|
|
127
|
+
<dict>
|
|
128
|
+
${Object.entries(env)
|
|
129
|
+
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
|
130
|
+
.join("\n")}
|
|
131
|
+
</dict>
|
|
132
|
+
`;
|
|
133
|
+
const contents = `<?xml version="1.0" encoding="UTF-8"?>
|
|
134
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
135
|
+
<!-- resumecontext-cli-version: ${xmlEscape(version)} -->
|
|
136
|
+
<plist version="1.0">
|
|
137
|
+
<dict>
|
|
138
|
+
<key>Label</key>
|
|
139
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
140
|
+
<key>ProgramArguments</key>
|
|
141
|
+
<array>
|
|
142
|
+
${args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n")}
|
|
143
|
+
</array>
|
|
144
|
+
${envBlock} <key>RunAtLoad</key>
|
|
145
|
+
<true/>
|
|
146
|
+
<key>StartInterval</key>
|
|
147
|
+
<integer>${DAEMON_INTERVAL_SECONDS}</integer>
|
|
148
|
+
<key>StandardOutPath</key>
|
|
149
|
+
<string>${xmlEscape(logFile)}</string>
|
|
150
|
+
<key>StandardErrorPath</key>
|
|
151
|
+
<string>${xmlEscape(logFile)}</string>
|
|
152
|
+
</dict>
|
|
153
|
+
</plist>
|
|
154
|
+
`;
|
|
155
|
+
return {
|
|
156
|
+
files: [{ path: plistPath, contents }],
|
|
157
|
+
// `launchctl list <label>` exits non-zero when the label isn't loaded.
|
|
158
|
+
// Deliberately NOT KeepAlive: that restarts the process the instant it
|
|
159
|
+
// exits, which for a one-shot tick means immediately, fighting
|
|
160
|
+
// StartInterval instead of waiting for it. launchd's own singleton
|
|
161
|
+
// behavior per label already means it won't start a second instance
|
|
162
|
+
// of this label while StartInterval's previous invocation is still
|
|
163
|
+
// running (daemon.ts's file lock is the belt-and-suspenders backstop
|
|
164
|
+
// for that, portable to systemd where the same guarantee is less
|
|
165
|
+
// absolute).
|
|
166
|
+
statusCommand: ["launchctl", "list", LAUNCHD_LABEL],
|
|
167
|
+
installCommands: [["launchctl", "load", "-w", plistPath]],
|
|
168
|
+
uninstallCommands: [["launchctl", "unload", "-w", plistPath]],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Same `version` parameter and reasoning as buildLaunchdPlan above. */
|
|
172
|
+
export function buildSystemdPlan(homeDir = os.homedir(), version = cliVersion()) {
|
|
173
|
+
const args = daemonProgramArguments();
|
|
174
|
+
const logFile = daemonLogFile();
|
|
175
|
+
const { systemdService: SYSTEMD_SERVICE_UNIT, systemdTimer: SYSTEMD_TIMER_UNIT } = serviceNames();
|
|
176
|
+
const systemdUserDir = path.join(homeDir, ".config", "systemd", "user");
|
|
177
|
+
const servicePath = path.join(systemdUserDir, SYSTEMD_SERVICE_UNIT);
|
|
178
|
+
const timerPath = path.join(systemdUserDir, SYSTEMD_TIMER_UNIT);
|
|
179
|
+
const quote = (s) => (/\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
|
|
180
|
+
const envLines = Object.entries(daemonEnvironment())
|
|
181
|
+
.map(([k, v]) => `Environment=${k}=${quote(v)}\n`)
|
|
182
|
+
.join("");
|
|
183
|
+
const serviceContents = `# resumecontext-cli-version: ${version}
|
|
184
|
+
[Unit]
|
|
185
|
+
Description=resumecontext auto-sync (one tick)
|
|
186
|
+
|
|
187
|
+
[Service]
|
|
188
|
+
Type=oneshot
|
|
189
|
+
${envLines}ExecStart=${args.map(quote).join(" ")}
|
|
190
|
+
StandardOutput=append:${logFile}
|
|
191
|
+
StandardError=append:${logFile}
|
|
192
|
+
`;
|
|
193
|
+
// OnBootSec=0 fires the first tick immediately once the timer starts
|
|
194
|
+
// (mirrors launchd's RunAtLoad); OnUnitActiveSec counts from when the
|
|
195
|
+
// triggered service last finished, not from a fixed clock, so a slow
|
|
196
|
+
// tick doesn't cause the next one to fire early or overlap.
|
|
197
|
+
const timerContents = `[Unit]
|
|
198
|
+
Description=resumecontext auto-sync timer
|
|
199
|
+
|
|
200
|
+
[Timer]
|
|
201
|
+
OnBootSec=0
|
|
202
|
+
OnUnitActiveSec=${DAEMON_INTERVAL_SECONDS}s
|
|
203
|
+
Unit=${SYSTEMD_SERVICE_UNIT}
|
|
204
|
+
|
|
205
|
+
[Install]
|
|
206
|
+
WantedBy=timers.target
|
|
207
|
+
`;
|
|
208
|
+
return {
|
|
209
|
+
files: [
|
|
210
|
+
{ path: servicePath, contents: serviceContents },
|
|
211
|
+
{ path: timerPath, contents: timerContents },
|
|
212
|
+
],
|
|
213
|
+
// Checks the TIMER, not the oneshot service -- see ServicePlan's doc.
|
|
214
|
+
statusCommand: ["systemctl", "--user", "is-active", "--quiet", SYSTEMD_TIMER_UNIT],
|
|
215
|
+
// Enabling/starting the TIMER is what schedules the service; the
|
|
216
|
+
// service unit itself is never enabled or started directly.
|
|
217
|
+
installCommands: [
|
|
218
|
+
["systemctl", "--user", "daemon-reload"],
|
|
219
|
+
["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT],
|
|
220
|
+
],
|
|
221
|
+
uninstallCommands: [["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT]],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
export function buildPlan(platform, homeDir, version) {
|
|
225
|
+
if (platform === "launchd")
|
|
226
|
+
return buildLaunchdPlan(homeDir, version);
|
|
227
|
+
if (platform === "systemd")
|
|
228
|
+
return buildSystemdPlan(homeDir, version);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function defaultExec(command, args) {
|
|
232
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
233
|
+
}
|
|
234
|
+
/** True if `plan`'s schedule is already registered/active. */
|
|
235
|
+
export function isServiceActive(execImpl = defaultExec, homeDir) {
|
|
236
|
+
const plan = buildPlan(detectPlatform(), homeDir);
|
|
237
|
+
if (!plan)
|
|
238
|
+
return false;
|
|
239
|
+
try {
|
|
240
|
+
execImpl(plan.statusCommand[0], plan.statusCommand.slice(1));
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function readFileIfPresent(fsImpl, filePath) {
|
|
248
|
+
try {
|
|
249
|
+
return fsImpl.readFileSync(filePath, "utf8");
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null; // never installed, or the file was removed out from under us
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** True only if EVERY file in the plan is present on disk with exactly
|
|
256
|
+
* the contents we'd write today -- systemd's plan is two files (service +
|
|
257
|
+
* timer), and either one being stale is enough to warrant reinstalling
|
|
258
|
+
* both, since they're only ever meaningful as a pair. */
|
|
259
|
+
function planUpToDate(fsImpl, plan) {
|
|
260
|
+
return plan.files.every((f) => readFileIfPresent(fsImpl, f.path) === f.contents);
|
|
261
|
+
}
|
|
262
|
+
export function installPersistentService(fsImpl = fs, execImpl = defaultExec, homeDir) {
|
|
263
|
+
const platform = detectPlatform();
|
|
264
|
+
const plan = buildPlan(platform, homeDir);
|
|
265
|
+
if (!plan)
|
|
266
|
+
return { platform, installed: false };
|
|
267
|
+
// "Already active" is NOT enough to skip: what's registered could be
|
|
268
|
+
// running the WRONG program, or an OUTDATED one. Two distinct cases,
|
|
269
|
+
// both covered by the same plain string comparison:
|
|
270
|
+
// - the launch path changed: daemonProgramArguments() bakes in
|
|
271
|
+
// process.execPath/argv[1], so a new versioned install path, a moved
|
|
272
|
+
// checkout, or a first run under `tsx` followed by a real install
|
|
273
|
+
// leave the schedule pointing somewhere stale.
|
|
274
|
+
// - an in-place upgrade at the SAME path (e.g. `npm install -g` over an
|
|
275
|
+
// existing global install): the launch path doesn't change, but the
|
|
276
|
+
// code on disk did. buildLaunchdPlan/buildSystemdPlan embed
|
|
277
|
+
// cliVersion() as a comment specifically so this case also shows up
|
|
278
|
+
// as a content difference.
|
|
279
|
+
// Either way: compare every on-disk file against what we'd write now,
|
|
280
|
+
// and treat any difference as "must reinstall".
|
|
281
|
+
if (planUpToDate(fsImpl, plan) && isServiceActive(execImpl, homeDir))
|
|
282
|
+
return { platform, installed: false };
|
|
283
|
+
for (const file of plan.files) {
|
|
284
|
+
fsImpl.mkdirSync(path.dirname(file.path), { recursive: true });
|
|
285
|
+
fsImpl.writeFileSync(file.path, file.contents);
|
|
286
|
+
}
|
|
287
|
+
// Unload the stale definition before loading the new one -- `launchctl
|
|
288
|
+
// load` on an already-loaded label is an error, and a stale systemd
|
|
289
|
+
// timer would otherwise keep firing the OLD service definition until
|
|
290
|
+
// restarted. Failure here is expected and ignored: it just means
|
|
291
|
+
// nothing was loaded to begin with.
|
|
292
|
+
for (const [cmd, ...args] of plan.uninstallCommands) {
|
|
293
|
+
try {
|
|
294
|
+
execImpl(cmd, args);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// not currently loaded -- nothing to unload
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const [cmd, ...args] of plan.installCommands)
|
|
301
|
+
execImpl(cmd, args);
|
|
302
|
+
return { platform, installed: true };
|
|
303
|
+
}
|
|
304
|
+
export function uninstallPersistentService(execImpl = defaultExec, homeDir, fsImpl = fs) {
|
|
305
|
+
const platform = detectPlatform();
|
|
306
|
+
const plan = buildPlan(platform, homeDir);
|
|
307
|
+
if (!plan)
|
|
308
|
+
return { platform, uninstalled: false };
|
|
309
|
+
const wasActive = isServiceActive(execImpl, homeDir);
|
|
310
|
+
for (const [cmd, ...args] of plan.uninstallCommands) {
|
|
311
|
+
try {
|
|
312
|
+
execImpl(cmd, args);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// not currently loaded/enabled -- nothing to unload
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let removedFiles = false;
|
|
319
|
+
for (const file of plan.files) {
|
|
320
|
+
if (fsImpl.existsSync(file.path)) {
|
|
321
|
+
fsImpl.unlinkSync(file.path);
|
|
322
|
+
removedFiles = true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return { platform, uninstalled: wasActive || removedFiles };
|
|
326
|
+
}
|
package/dist/deps.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/dev.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npm run dev` -- the CLI pointed at a backend running on this machine.
|
|
3
|
+
*
|
|
4
|
+
* Two overrides, both of which the CLI already honours; this file only
|
|
5
|
+
* supplies dev defaults for them so nobody has to remember the incantation:
|
|
6
|
+
*
|
|
7
|
+
* RESUMECONTEXT_API_URL the backend. `npm run dev` in ../backend listens
|
|
8
|
+
* on 3001, and that backend's own API_URL and
|
|
9
|
+
* WEBSITE_URL point at localhost, so the sign-in URL
|
|
10
|
+
* and the MCP endpoint it hands back are local too.
|
|
11
|
+
* Nothing else needs configuring.
|
|
12
|
+
*
|
|
13
|
+
* RESUMECONTEXT_HOME local state. Pointed at a separate directory so a
|
|
14
|
+
* dev sign-in does not overwrite the credentials and
|
|
15
|
+
* device id of the real CLI, whose tokens are issued
|
|
16
|
+
* by a different backend and would be rejected by it.
|
|
17
|
+
*
|
|
18
|
+
* Both are only defaults: setting either in the environment wins, so this
|
|
19
|
+
* still works for pointing at a staging backend or sharing the real home.
|
|
20
|
+
*
|
|
21
|
+
* The daemon is handled too. daemonProgramArguments() bakes `process.argv[1]`
|
|
22
|
+
* into the service file, which for this entrypoint is this file -- so a daemon
|
|
23
|
+
* registered by the dev CLI re-enters here on every tick and picks the same
|
|
24
|
+
* two defaults back up. It also gets them written into the service file
|
|
25
|
+
* explicitly (see daemonService.ts), because an exported variable is not
|
|
26
|
+
* inherited by launchd or systemd.
|
|
27
|
+
*/
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
process.env.RESUMECONTEXT_API_URL ||= "http://localhost:3001";
|
|
31
|
+
process.env.RESUMECONTEXT_HOME ||= path.join(os.homedir(), ".resumecontext-dev");
|
|
32
|
+
await import("./index.js");
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This machine's identity.
|
|
3
|
+
*
|
|
4
|
+
* A bare UUID, generated once and kept in the CLI's home directory rather than
|
|
5
|
+
* in any project: it identifies the MACHINE, and the same machine syncs many
|
|
6
|
+
* projects. There is deliberately no human-readable name -- a hostname would
|
|
7
|
+
* be shared with everyone on a project without anyone choosing to share it,
|
|
8
|
+
* and nothing in the product needs to display one. The id only ever has to
|
|
9
|
+
* answer "same machine or not".
|
|
10
|
+
*
|
|
11
|
+
* It is sent once, when starting a device sign-in, and the backend binds it
|
|
12
|
+
* into the issued token. Every later push and MCP call is then attributable to
|
|
13
|
+
* this machine without the CLI sending anything further, and without a client
|
|
14
|
+
* being able to claim a machine that is not its own.
|
|
15
|
+
*/
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { deviceFile } from "./paths.js";
|
|
20
|
+
/** The server rejects anything that is not a UUID, so a stored value that no
|
|
21
|
+
* longer looks like one is treated as corrupt and replaced -- otherwise a
|
|
22
|
+
* machine whose device.json got mangled could never sign in again. */
|
|
23
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
export function deviceId() {
|
|
25
|
+
const file = deviceFile();
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
28
|
+
if (typeof parsed?.id === "string" && UUID_RE.test(parsed.id))
|
|
29
|
+
return parsed.id;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Missing or corrupt -- fall through and mint a new one. A machine that
|
|
33
|
+
// loses its id looks like a new machine, which is a harmless degradation:
|
|
34
|
+
// older sessions read as "another machine of yours" rather than "here".
|
|
35
|
+
}
|
|
36
|
+
const id = crypto.randomUUID();
|
|
37
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
38
|
+
fs.writeFileSync(file, JSON.stringify({ id }, null, 2));
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real CloudApi implementation -- every method is one axios call
|
|
3
|
+
* through the shared apiClient (see apiClient.ts), which is also where all
|
|
4
|
+
* 401 handling lives. Every command already goes through this same
|
|
5
|
+
* interface, so nothing above this file needs to know or care that it's
|
|
6
|
+
* now backed by real HTTP instead of an in-process fake.
|
|
7
|
+
*/
|
|
8
|
+
import { apiClient } from "./apiClient.js";
|
|
9
|
+
import { deviceId } from "./device.js";
|
|
10
|
+
import { API_VERSION } from "./constants.js";
|
|
11
|
+
function bearer(token) {
|
|
12
|
+
return { headers: { Authorization: `Bearer ${token}` } };
|
|
13
|
+
}
|
|
14
|
+
export class HttpCloudApi {
|
|
15
|
+
async startDeviceLogin() {
|
|
16
|
+
// The machine id goes in at the START of the flow, so whichever way the
|
|
17
|
+
// browser finishes it -- a fresh Google sign-in, or an existing web
|
|
18
|
+
// session -- the token that comes back is bound to this machine.
|
|
19
|
+
const { data } = await apiClient.post("/auth/device/start", {
|
|
20
|
+
deviceId: deviceId(),
|
|
21
|
+
});
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
async pollDeviceLogin(deviceCode) {
|
|
25
|
+
const { data } = await apiClient.get(`/auth/device/poll/${deviceCode}`);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
async createProject(token, label) {
|
|
29
|
+
const { data } = await apiClient.post(`/${API_VERSION}/projects`, { label }, bearer(token));
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
async findProject(token, projectId) {
|
|
33
|
+
const { data } = await apiClient.get(`/${API_VERSION}/projects/${projectId}`, bearer(token));
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
async pushTurns(token, projectId, turns) {
|
|
37
|
+
const { data } = await apiClient.post(`/${API_VERSION}/projects/${projectId}/turns`, { turns }, bearer(token));
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
async shareProject(token, projectId, email) {
|
|
41
|
+
await apiClient.post(`/${API_VERSION}/projects/${projectId}/members`, { email }, bearer(token));
|
|
42
|
+
}
|
|
43
|
+
async acceptInvite(token, projectId) {
|
|
44
|
+
const { data } = await apiClient.post(`/${API_VERSION}/projects/${projectId}/accept`, {}, bearer(token));
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
async revokeAccess(token, projectId, email) {
|
|
48
|
+
await apiClient.delete(`/${API_VERSION}/projects/${projectId}/members/${encodeURIComponent(email)}`, bearer(token));
|
|
49
|
+
}
|
|
50
|
+
async listMembers(token, projectId) {
|
|
51
|
+
const { data } = await apiClient.get(`/${API_VERSION}/projects/${projectId}/members`, bearer(token));
|
|
52
|
+
return data.members;
|
|
53
|
+
}
|
|
54
|
+
async listProjects(token) {
|
|
55
|
+
const { data } = await apiClient.get(`/${API_VERSION}/projects`, bearer(token));
|
|
56
|
+
return data.projects;
|
|
57
|
+
}
|
|
58
|
+
async deleteProject(token, projectId) {
|
|
59
|
+
await apiClient.delete(`/${API_VERSION}/projects/${projectId}`, bearer(token));
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Commander wiring: routes CLI args to the pure command functions in
|
|
4
|
+
* commands/*.ts, injecting the real Deps (HttpCloudApi -- real HTTP via the
|
|
5
|
+
* shared axios client against api.resumecontext.com, and
|
|
6
|
+
* RealLocalCapture reading each agent's own local history via
|
|
7
|
+
* localHistory/). This is the ONLY file that
|
|
8
|
+
* constructs real dependencies or touches process.exit for error handling
|
|
9
|
+
* -- every command function itself just throws a plain Error with a
|
|
10
|
+
* user-facing message and returns.
|
|
11
|
+
*/
|
|
12
|
+
import { Command } from "commander";
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
import { HttpCloudApi } from "./httpCloudApi.js";
|
|
15
|
+
import { RealLocalCapture } from "./localCapture.js";
|
|
16
|
+
import { ensureAgentConfigWithDaemon } from "./agentConfigWithDaemon.js";
|
|
17
|
+
import { runDaemonTick } from "./daemon.js";
|
|
18
|
+
import { DAEMON_SUBCOMMAND } from "./daemonService.js";
|
|
19
|
+
import { cliVersion } from "./version.js";
|
|
20
|
+
import * as ui from "./ui.js";
|
|
21
|
+
import { runAuth } from "./commands/auth.js";
|
|
22
|
+
import { runInit } from "./commands/init.js";
|
|
23
|
+
import { runSync } from "./commands/sync.js";
|
|
24
|
+
import { runShare } from "./commands/share.js";
|
|
25
|
+
import { runAccept } from "./commands/accept.js";
|
|
26
|
+
import { runRevoke } from "./commands/revoke.js";
|
|
27
|
+
import { runMembers } from "./commands/members.js";
|
|
28
|
+
import { runMcp } from "./commands/mcp.js";
|
|
29
|
+
import { runAgents } from "./commands/agents.js";
|
|
30
|
+
import { runLogout } from "./commands/logout.js";
|
|
31
|
+
import { runDaemonStatus, runDaemonStart, runDaemonStop } from "./commands/daemon.js";
|
|
32
|
+
import { runProjectsList, runProjectsDelete } from "./commands/projects.js";
|
|
33
|
+
import { runUninstall } from "./commands/uninstall.js";
|
|
34
|
+
const cloudApi = new HttpCloudApi();
|
|
35
|
+
const localCapture = new RealLocalCapture();
|
|
36
|
+
function deps() {
|
|
37
|
+
return { cloudApi, cwd: process.cwd(), ensureAgentConfig: ensureAgentConfigWithDaemon };
|
|
38
|
+
}
|
|
39
|
+
function syncDeps() {
|
|
40
|
+
return { ...deps(), localCapture };
|
|
41
|
+
}
|
|
42
|
+
function withErrorHandling(action) {
|
|
43
|
+
return async (...args) => {
|
|
44
|
+
try {
|
|
45
|
+
await action(...args);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
ui.printError(err);
|
|
49
|
+
if (program.opts().debug && err instanceof Error && err.stack) {
|
|
50
|
+
console.error(chalk.dim(err.stack));
|
|
51
|
+
}
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const program = new Command();
|
|
57
|
+
program
|
|
58
|
+
.name("resumecontext")
|
|
59
|
+
.description("Shared context for coding agents -- resumecontext.com")
|
|
60
|
+
.version(cliVersion())
|
|
61
|
+
.option("--debug", "show full stack traces on unexpected errors");
|
|
62
|
+
program
|
|
63
|
+
.command("auth")
|
|
64
|
+
.description("log in to resumecontext.com (opens your browser)")
|
|
65
|
+
.action(withErrorHandling(() => runAuth(deps())));
|
|
66
|
+
program
|
|
67
|
+
.command("logout")
|
|
68
|
+
.description("clear this machine's stored session")
|
|
69
|
+
.action(withErrorHandling(() => runLogout()));
|
|
70
|
+
program
|
|
71
|
+
.command("init")
|
|
72
|
+
.description("connect this directory to a resumecontext project")
|
|
73
|
+
.action(withErrorHandling(() => runInit(deps())));
|
|
74
|
+
program
|
|
75
|
+
.command("sync")
|
|
76
|
+
.description("push local coding-agent history for this project")
|
|
77
|
+
.action(withErrorHandling(() => runSync(syncDeps())));
|
|
78
|
+
program
|
|
79
|
+
.command("share")
|
|
80
|
+
.description("invite someone to this project (owner only)")
|
|
81
|
+
.argument("<email>", "email address to invite")
|
|
82
|
+
.action(withErrorHandling((email) => runShare(deps(), email)));
|
|
83
|
+
program
|
|
84
|
+
.command("accept")
|
|
85
|
+
.description("accept a pending invite for this project")
|
|
86
|
+
.action(withErrorHandling(() => runAccept(deps())));
|
|
87
|
+
program
|
|
88
|
+
.command("revoke")
|
|
89
|
+
.description("remove someone's access to this project (owner only)")
|
|
90
|
+
.argument("<email>", "email address to remove")
|
|
91
|
+
.action(withErrorHandling((email) => runRevoke(deps(), email)));
|
|
92
|
+
program
|
|
93
|
+
.command("members")
|
|
94
|
+
.description("list everyone with access to this project (owner only)")
|
|
95
|
+
.action(withErrorHandling(() => runMembers(deps())));
|
|
96
|
+
program
|
|
97
|
+
.command("mcp")
|
|
98
|
+
.description("show the MCP URL and token for configuring your coding agent")
|
|
99
|
+
.action(withErrorHandling(() => runMcp(deps())));
|
|
100
|
+
program
|
|
101
|
+
.command("agents")
|
|
102
|
+
.description("choose which coding agents to sync from, and where their data lives")
|
|
103
|
+
.action(withErrorHandling(() => runAgents(deps())));
|
|
104
|
+
const daemonCmd = program.command("daemon").description("manage the background auto-sync service");
|
|
105
|
+
daemonCmd
|
|
106
|
+
.command("status")
|
|
107
|
+
.description("show whether auto-sync is running")
|
|
108
|
+
.action(withErrorHandling(() => runDaemonStatus()));
|
|
109
|
+
daemonCmd
|
|
110
|
+
.command("start")
|
|
111
|
+
.description("(re)enable auto-sync -- rarely needed, every project command does this on its own")
|
|
112
|
+
.action(withErrorHandling(() => runDaemonStart()));
|
|
113
|
+
daemonCmd
|
|
114
|
+
.command("stop")
|
|
115
|
+
.description("stop auto-sync (restarts next time you run a project command like `sync`)")
|
|
116
|
+
.action(withErrorHandling(() => runDaemonStop()));
|
|
117
|
+
const projectsCmd = program.command("projects").description("manage your resumecontext projects");
|
|
118
|
+
projectsCmd
|
|
119
|
+
.command("list")
|
|
120
|
+
.description("list every project you own or have access to")
|
|
121
|
+
.action(withErrorHandling(() => runProjectsList(deps())));
|
|
122
|
+
projectsCmd
|
|
123
|
+
.command("delete")
|
|
124
|
+
.description("permanently delete a project (owner only)")
|
|
125
|
+
.argument("<projectId>", "project id, from `resumecontext projects list`")
|
|
126
|
+
.action(withErrorHandling((projectId) => runProjectsDelete(deps(), projectId)));
|
|
127
|
+
program
|
|
128
|
+
.command("uninstall")
|
|
129
|
+
.description("stop auto-sync and delete all local resumecontext state (asks to confirm)")
|
|
130
|
+
.action(withErrorHandling(() => runUninstall()));
|
|
131
|
+
// Hidden: not something a user runs directly. This is the entry point the
|
|
132
|
+
// OS scheduler invokes for EVERY tick -- the launchd plist's
|
|
133
|
+
// ProgramArguments (StartInterval) and the systemd timer's triggered
|
|
134
|
+
// oneshot service (ExecStart) both re-invoke this same binary with this
|
|
135
|
+
// subcommand every DAEMON_INTERVAL_MS (see daemonProgramArguments in
|
|
136
|
+
// daemonService.ts). Each invocation is a fresh process that runs one tick
|
|
137
|
+
// and exits -- see daemon.ts's module doc comment for why that's the
|
|
138
|
+
// design (self-healing from a hung/crashed tick) rather than one
|
|
139
|
+
// persistent process looping forever.
|
|
140
|
+
//
|
|
141
|
+
// Explicitly calls process.exit() rather than letting the process exit
|
|
142
|
+
// naturally once the event loop empties: an open keep-alive HTTP
|
|
143
|
+
// connection to the cloud API (or anything else holding a handle open)
|
|
144
|
+
// would otherwise leave the process alive indefinitely, and since it
|
|
145
|
+
// still holds daemon.ts's lock file while alive, that would silently wedge
|
|
146
|
+
// every future tick forever -- the exact failure mode this redesign
|
|
147
|
+
// exists to eliminate. Bypasses withErrorHandling for the same reason
|
|
148
|
+
// runDaemonLoop used to: that wrapper prints to the terminal and sets
|
|
149
|
+
// process.exitCode for an interactive command, neither of which means
|
|
150
|
+
// anything for a background process with no terminal.
|
|
151
|
+
program
|
|
152
|
+
.command(DAEMON_SUBCOMMAND, { hidden: true })
|
|
153
|
+
.action(() => runDaemonTick(cloudApi, localCapture).then(() => process.exit(0), (err) => {
|
|
154
|
+
console.error(err);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}));
|
|
157
|
+
async function main() {
|
|
158
|
+
await program.parseAsync(process.argv);
|
|
159
|
+
}
|
|
160
|
+
main();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behind the LocalCapture interface so command tests never need real local
|
|
3
|
+
* ~/.claude / ~/.cursor / etc. data on the machine running them -- they
|
|
4
|
+
* inject a fake that returns canned turns instead. The real implementation
|
|
5
|
+
* (RealLocalCapture) just calls localHistory/'s in-process parser --
|
|
6
|
+
* nothing here shells out to another package or reads another package's
|
|
7
|
+
* output files; this CLI owns its own local-history parsing end to end.
|
|
8
|
+
*
|
|
9
|
+
* Takes the agent config as an argument rather than reading it: the config
|
|
10
|
+
* is keyed by projectId (see agentConfig.ts), which this layer has no way
|
|
11
|
+
* to know from a project root alone. The caller already resolved it.
|
|
12
|
+
*/
|
|
13
|
+
import { collectLocalTurns } from "./localHistory/index.js";
|
|
14
|
+
export class RealLocalCapture {
|
|
15
|
+
async scan(projectRoot, config) {
|
|
16
|
+
return collectLocalTurns(projectRoot, config);
|
|
17
|
+
}
|
|
18
|
+
}
|