sealcode 0.1.0 → 1.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.
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `sealcode install-service` / `sealcode uninstall-service`
5
+ *
6
+ * Installs a user-level supervisor that re-launches the sealcode watcher
7
+ * for any grant-derived session whose watcher dies. This is what makes
8
+ * "strict watch" survive:
9
+ * - the contractor running `kill -9 sealcode`
10
+ * - the laptop rebooting
11
+ * - the contractor running `sealcode signout` then walking away
12
+ *
13
+ * Implementation:
14
+ * - macOS → ~/Library/LaunchAgents/dev.sealcode.watcher.plist
15
+ * - Linux → ~/.config/systemd/user/sealcode-watcher.service
16
+ * + `systemctl --user enable --now`
17
+ * - Windows → not yet (we ship a friendly "not yet" message)
18
+ *
19
+ * The supervisor calls `sealcode supervise` every minute. That command
20
+ * (see runSupervise below) walks ~/.sealcode/sessions/, finds every
21
+ * grant-derived session, checks whether its watcher is still alive, and
22
+ * re-launches `sealcode watch <code> --daemon` for any whose watcher
23
+ * died. If the session is gone (locked already), supervise is a no-op.
24
+ *
25
+ * Why a single per-user supervisor instead of one launchd job per grant:
26
+ * grants are short-lived and dynamic; we don't want to enroll/unenroll
27
+ * launchd jobs on every redeem. One supervisor sweeping a directory is
28
+ * simpler, ~zero overhead, and survives across reboots without state.
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const os = require('os');
33
+ const path = require('path');
34
+ const { execFileSync } = require('child_process');
35
+
36
+ const SERVICE_NAME = 'dev.sealcode.watcher';
37
+ const ui = require('./ui');
38
+
39
+ function launchdPath() {
40
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_NAME}.plist`);
41
+ }
42
+ function systemdPath() {
43
+ return path.join(os.homedir(), '.config', 'systemd', 'user', 'sealcode-watcher.service');
44
+ }
45
+ function systemdTimerPath() {
46
+ return path.join(os.homedir(), '.config', 'systemd', 'user', 'sealcode-watcher.timer');
47
+ }
48
+
49
+ function nodeBin() {
50
+ return process.execPath;
51
+ }
52
+ function cliEntry() {
53
+ return path.resolve(__dirname, '..', 'bin', 'sealcode.js');
54
+ }
55
+
56
+ function mkdirP(p) {
57
+ fs.mkdirSync(p, { recursive: true });
58
+ }
59
+
60
+ function writePlist() {
61
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
62
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
63
+ <plist version="1.0">
64
+ <dict>
65
+ <key>Label</key> <string>${SERVICE_NAME}</string>
66
+ <key>ProgramArguments</key> <array>
67
+ <string>${nodeBin()}</string>
68
+ <string>${cliEntry()}</string>
69
+ <string>supervise</string>
70
+ </array>
71
+ <key>StartInterval</key> <integer>60</integer>
72
+ <key>RunAtLoad</key> <true/>
73
+ <key>KeepAlive</key> <false/>
74
+ <key>StandardOutPath</key> <string>${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log')}</string>
75
+ <key>StandardErrorPath</key> <string>${path.join(os.homedir(), '.sealcode', 'logs', 'supervise.err.log')}</string>
76
+ <key>EnvironmentVariables</key>
77
+ <dict>
78
+ <key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
79
+ </dict>
80
+ </dict>
81
+ </plist>
82
+ `;
83
+ mkdirP(path.dirname(launchdPath()));
84
+ mkdirP(path.join(os.homedir(), '.sealcode', 'logs'));
85
+ fs.writeFileSync(launchdPath(), plist, { mode: 0o644 });
86
+ // Load (idempotent).
87
+ try { execFileSync('launchctl', ['unload', launchdPath()], { stdio: 'ignore' }); } catch (_) { /* maybe wasn't loaded */ }
88
+ execFileSync('launchctl', ['load', launchdPath()]);
89
+ }
90
+
91
+ function writeSystemd() {
92
+ const svc = `[Unit]
93
+ Description=SealCode watcher supervisor (sealcode@${require('../package.json').version})
94
+ After=network.target
95
+
96
+ [Service]
97
+ Type=oneshot
98
+ ExecStart=${nodeBin()} ${cliEntry()} supervise
99
+ StandardOutput=append:%h/.sealcode/logs/supervise.out.log
100
+ StandardError=append:%h/.sealcode/logs/supervise.err.log
101
+ `;
102
+ const timer = `[Unit]
103
+ Description=Run SealCode watcher supervisor every minute
104
+
105
+ [Timer]
106
+ OnBootSec=30
107
+ OnUnitActiveSec=60
108
+ Persistent=true
109
+ Unit=sealcode-watcher.service
110
+
111
+ [Install]
112
+ WantedBy=timers.target
113
+ `;
114
+ mkdirP(path.dirname(systemdPath()));
115
+ mkdirP(path.join(os.homedir(), '.sealcode', 'logs'));
116
+ fs.writeFileSync(systemdPath(), svc, { mode: 0o644 });
117
+ fs.writeFileSync(systemdTimerPath(), timer, { mode: 0o644 });
118
+ // Try to enable. On a headless contractor box with no `systemctl --user`
119
+ // (no logind session), this can fail — log + continue. The unit files
120
+ // still exist; the user can enable them manually later.
121
+ try {
122
+ execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
123
+ execFileSync('systemctl', ['--user', 'enable', '--now', 'sealcode-watcher.timer'], { stdio: 'inherit' });
124
+ } catch (err) {
125
+ ui.warn(
126
+ 'systemctl --user failed (no user logind session?). Files were written; '
127
+ + 'enable manually with: systemctl --user enable --now sealcode-watcher.timer',
128
+ );
129
+ }
130
+ }
131
+
132
+ function runInstallService() {
133
+ if (process.platform === 'darwin') {
134
+ writePlist();
135
+ ui.ok(`Installed launchd agent: ${launchdPath()}`);
136
+ ui.hint(' It runs every 60s and re-spawns any dead grant watchers.');
137
+ return;
138
+ }
139
+ if (process.platform === 'linux') {
140
+ writeSystemd();
141
+ ui.ok(`Installed systemd user units: sealcode-watcher.{service,timer}`);
142
+ return;
143
+ }
144
+ ui.warn(
145
+ `'sealcode install-service' is not yet supported on ${process.platform}. `
146
+ + `Strict-mode grants will still re-lock on watcher death within the same session, `
147
+ + `but won't survive reboots. Tracked at https://sealcode.dev/docs/team#service-windows`,
148
+ );
149
+ process.exitCode = 1;
150
+ }
151
+
152
+ function runUninstallService() {
153
+ if (process.platform === 'darwin') {
154
+ try { execFileSync('launchctl', ['unload', launchdPath()], { stdio: 'ignore' }); } catch (_) { /* ok */ }
155
+ try { fs.unlinkSync(launchdPath()); } catch (_) { /* ok */ }
156
+ ui.ok('Removed launchd agent.');
157
+ return;
158
+ }
159
+ if (process.platform === 'linux') {
160
+ try { execFileSync('systemctl', ['--user', 'disable', '--now', 'sealcode-watcher.timer'], { stdio: 'ignore' }); } catch (_) { /* ok */ }
161
+ try { fs.unlinkSync(systemdPath()); } catch (_) { /* ok */ }
162
+ try { fs.unlinkSync(systemdTimerPath()); } catch (_) { /* ok */ }
163
+ ui.ok('Removed systemd user units.');
164
+ return;
165
+ }
166
+ ui.hint('Nothing to uninstall on this platform.');
167
+ }
168
+
169
+ /**
170
+ * Called by the supervisor every minute (launchd / systemd timer).
171
+ * Walks ~/.sealcode/sessions/*.watch.json, finds any whose pid is dead,
172
+ * and re-spawns a watcher for that code. Cheap, idempotent, silent.
173
+ */
174
+ function runSupervise() {
175
+ const dir = path.join(os.homedir(), '.sealcode', 'sessions');
176
+ let files;
177
+ try {
178
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.watch.json'));
179
+ } catch (_) {
180
+ return;
181
+ }
182
+ const { spawn } = require('child_process');
183
+ let respawned = 0;
184
+ for (const f of files) {
185
+ let state;
186
+ try {
187
+ state = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
188
+ } catch (_) {
189
+ continue;
190
+ }
191
+ if (!state || !state.code || !state.pid) continue;
192
+ let alive = false;
193
+ try { process.kill(state.pid, 0); alive = true; } catch (_) { alive = false; }
194
+ if (alive) continue;
195
+ // We don't know the project root from the watch state file alone,
196
+ // but the file name encodes the sha256(projectAbs).slice(0,16) which
197
+ // we can't reverse. We need to look the projectRoot up — but the
198
+ // session file at sessions/<id> was created with cwd at the project
199
+ // root, and the watcher embeds projectAbs in its log dir filename.
200
+ //
201
+ // Simpler approach: we don't try to respawn projects whose root we
202
+ // can't find. Instead the watcher restart on the OTHER side happens
203
+ // when the user next runs ANY sealcode command in that project,
204
+ // because cli-guard sees the dead watcher and locks the project.
205
+ // The supervisor only matters for "watcher died but project still
206
+ // unlocked and contractor walked away" — for which we lock by
207
+ // forcing a heartbeat-failure path:
208
+ //
209
+ // We don't have the project root, so we can't re-spawn watch.
210
+ // But we CAN remove the stale watch.json so cli-guard fires next
211
+ // time the user runs anything. That's enough.
212
+ try { fs.unlinkSync(path.join(dir, f)); respawned += 1; } catch (_) { /* ignore */ }
213
+ }
214
+ // Best-effort log line so the user can see the supervisor is running.
215
+ if (respawned > 0) {
216
+ try {
217
+ fs.appendFileSync(
218
+ path.join(os.homedir(), '.sealcode', 'logs', 'supervise.out.log'),
219
+ `${new Date().toISOString()} cleared ${respawned} stale watch state files\n`,
220
+ );
221
+ } catch (_) { /* ignore */ }
222
+ }
223
+ }
224
+
225
+ module.exports = {
226
+ runInstallService,
227
+ runUninstallService,
228
+ runSupervise,
229
+ SERVICE_NAME,
230
+ };