hacklab 0.22.3 → 26.921.2

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.
Files changed (259) hide show
  1. package/README.md +39 -415
  2. package/dist/index.js +317 -23
  3. package/package.json +22 -40
  4. package/LICENSE +0 -21
  5. package/dist/agent-handoff.d.ts +0 -51
  6. package/dist/agent-handoff.d.ts.map +0 -1
  7. package/dist/agent-handoff.js +0 -91
  8. package/dist/agent-handoff.js.map +0 -1
  9. package/dist/api-client.d.ts +0 -18
  10. package/dist/api-client.d.ts.map +0 -1
  11. package/dist/api-client.js +0 -40
  12. package/dist/api-client.js.map +0 -1
  13. package/dist/belt.d.ts +0 -25
  14. package/dist/belt.d.ts.map +0 -1
  15. package/dist/belt.js +0 -113
  16. package/dist/belt.js.map +0 -1
  17. package/dist/card.d.ts +0 -66
  18. package/dist/card.d.ts.map +0 -1
  19. package/dist/card.js +0 -151
  20. package/dist/card.js.map +0 -1
  21. package/dist/cli.d.ts +0 -2
  22. package/dist/cli.d.ts.map +0 -1
  23. package/dist/cli.js +0 -238
  24. package/dist/cli.js.map +0 -1
  25. package/dist/commands/book.d.ts +0 -14
  26. package/dist/commands/book.d.ts.map +0 -1
  27. package/dist/commands/book.js +0 -90
  28. package/dist/commands/book.js.map +0 -1
  29. package/dist/commands/chat.d.ts +0 -36
  30. package/dist/commands/chat.d.ts.map +0 -1
  31. package/dist/commands/chat.js +0 -642
  32. package/dist/commands/chat.js.map +0 -1
  33. package/dist/commands/config.d.ts +0 -2
  34. package/dist/commands/config.d.ts.map +0 -1
  35. package/dist/commands/config.js +0 -84
  36. package/dist/commands/config.js.map +0 -1
  37. package/dist/commands/daemon.d.ts +0 -19
  38. package/dist/commands/daemon.d.ts.map +0 -1
  39. package/dist/commands/daemon.js +0 -72
  40. package/dist/commands/daemon.js.map +0 -1
  41. package/dist/commands/drop.d.ts +0 -12
  42. package/dist/commands/drop.d.ts.map +0 -1
  43. package/dist/commands/drop.js +0 -103
  44. package/dist/commands/drop.js.map +0 -1
  45. package/dist/commands/drops.d.ts +0 -9
  46. package/dist/commands/drops.d.ts.map +0 -1
  47. package/dist/commands/drops.js +0 -96
  48. package/dist/commands/drops.js.map +0 -1
  49. package/dist/commands/essay.d.ts +0 -40
  50. package/dist/commands/essay.d.ts.map +0 -1
  51. package/dist/commands/essay.js +0 -651
  52. package/dist/commands/essay.js.map +0 -1
  53. package/dist/commands/hackathon.d.ts +0 -2
  54. package/dist/commands/hackathon.d.ts.map +0 -1
  55. package/dist/commands/hackathon.js +0 -893
  56. package/dist/commands/hackathon.js.map +0 -1
  57. package/dist/commands/hacker.d.ts +0 -2
  58. package/dist/commands/hacker.d.ts.map +0 -1
  59. package/dist/commands/hacker.js +0 -87
  60. package/dist/commands/hacker.js.map +0 -1
  61. package/dist/commands/jobs.d.ts +0 -18
  62. package/dist/commands/jobs.d.ts.map +0 -1
  63. package/dist/commands/jobs.js +0 -138
  64. package/dist/commands/jobs.js.map +0 -1
  65. package/dist/commands/keys.d.ts +0 -2
  66. package/dist/commands/keys.d.ts.map +0 -1
  67. package/dist/commands/keys.js +0 -138
  68. package/dist/commands/keys.js.map +0 -1
  69. package/dist/commands/login.d.ts +0 -83
  70. package/dist/commands/login.d.ts.map +0 -1
  71. package/dist/commands/login.js +0 -236
  72. package/dist/commands/login.js.map +0 -1
  73. package/dist/commands/logout.d.ts +0 -2
  74. package/dist/commands/logout.d.ts.map +0 -1
  75. package/dist/commands/logout.js +0 -22
  76. package/dist/commands/logout.js.map +0 -1
  77. package/dist/commands/org-jobs.d.ts +0 -50
  78. package/dist/commands/org-jobs.d.ts.map +0 -1
  79. package/dist/commands/org-jobs.js +0 -383
  80. package/dist/commands/org-jobs.js.map +0 -1
  81. package/dist/commands/org.d.ts +0 -67
  82. package/dist/commands/org.d.ts.map +0 -1
  83. package/dist/commands/org.js +0 -1244
  84. package/dist/commands/org.js.map +0 -1
  85. package/dist/commands/ping.d.ts +0 -2
  86. package/dist/commands/ping.d.ts.map +0 -1
  87. package/dist/commands/ping.js +0 -50
  88. package/dist/commands/ping.js.map +0 -1
  89. package/dist/commands/profile.d.ts +0 -72
  90. package/dist/commands/profile.d.ts.map +0 -1
  91. package/dist/commands/profile.js +0 -672
  92. package/dist/commands/profile.js.map +0 -1
  93. package/dist/commands/project.d.ts +0 -2
  94. package/dist/commands/project.d.ts.map +0 -1
  95. package/dist/commands/project.js +0 -583
  96. package/dist/commands/project.js.map +0 -1
  97. package/dist/commands/referral.d.ts +0 -6
  98. package/dist/commands/referral.d.ts.map +0 -1
  99. package/dist/commands/referral.js +0 -93
  100. package/dist/commands/referral.js.map +0 -1
  101. package/dist/commands/rtfm.d.ts +0 -9
  102. package/dist/commands/rtfm.d.ts.map +0 -1
  103. package/dist/commands/rtfm.js +0 -114
  104. package/dist/commands/rtfm.js.map +0 -1
  105. package/dist/commands/scan.d.ts +0 -13
  106. package/dist/commands/scan.d.ts.map +0 -1
  107. package/dist/commands/scan.js +0 -286
  108. package/dist/commands/scan.js.map +0 -1
  109. package/dist/commands/scout.d.ts +0 -18
  110. package/dist/commands/scout.d.ts.map +0 -1
  111. package/dist/commands/scout.js +0 -232
  112. package/dist/commands/scout.js.map +0 -1
  113. package/dist/commands/setup-rail.d.ts +0 -58
  114. package/dist/commands/setup-rail.d.ts.map +0 -1
  115. package/dist/commands/setup-rail.js +0 -135
  116. package/dist/commands/setup-rail.js.map +0 -1
  117. package/dist/commands/setup.d.ts +0 -29
  118. package/dist/commands/setup.d.ts.map +0 -1
  119. package/dist/commands/setup.js +0 -442
  120. package/dist/commands/setup.js.map +0 -1
  121. package/dist/commands/sync.d.ts +0 -9
  122. package/dist/commands/sync.d.ts.map +0 -1
  123. package/dist/commands/sync.js +0 -345
  124. package/dist/commands/sync.js.map +0 -1
  125. package/dist/commands/update.d.ts +0 -2
  126. package/dist/commands/update.d.ts.map +0 -1
  127. package/dist/commands/update.js +0 -92
  128. package/dist/commands/update.js.map +0 -1
  129. package/dist/commands/whoami.d.ts +0 -2
  130. package/dist/commands/whoami.d.ts.map +0 -1
  131. package/dist/commands/whoami.js +0 -41
  132. package/dist/commands/whoami.js.map +0 -1
  133. package/dist/config.d.ts +0 -78
  134. package/dist/config.d.ts.map +0 -1
  135. package/dist/config.js +0 -93
  136. package/dist/config.js.map +0 -1
  137. package/dist/daily-sync.d.ts +0 -150
  138. package/dist/daily-sync.d.ts.map +0 -1
  139. package/dist/daily-sync.js +0 -665
  140. package/dist/daily-sync.js.map +0 -1
  141. package/dist/dossier.d.ts +0 -73
  142. package/dist/dossier.d.ts.map +0 -1
  143. package/dist/dossier.js +0 -141
  144. package/dist/dossier.js.map +0 -1
  145. package/dist/field-normalize.d.ts +0 -4
  146. package/dist/field-normalize.d.ts.map +0 -1
  147. package/dist/field-normalize.js +0 -8
  148. package/dist/field-normalize.js.map +0 -1
  149. package/dist/index.d.ts +0 -3
  150. package/dist/index.d.ts.map +0 -1
  151. package/dist/index.js.map +0 -1
  152. package/dist/job-fields.d.ts +0 -47
  153. package/dist/job-fields.d.ts.map +0 -1
  154. package/dist/job-fields.js +0 -148
  155. package/dist/job-fields.js.map +0 -1
  156. package/dist/machine.d.ts +0 -15
  157. package/dist/machine.d.ts.map +0 -1
  158. package/dist/machine.js +0 -64
  159. package/dist/machine.js.map +0 -1
  160. package/dist/org-fields.d.ts +0 -57
  161. package/dist/org-fields.d.ts.map +0 -1
  162. package/dist/org-fields.js +0 -167
  163. package/dist/org-fields.js.map +0 -1
  164. package/dist/posthog.d.ts +0 -38
  165. package/dist/posthog.d.ts.map +0 -1
  166. package/dist/posthog.js +0 -224
  167. package/dist/posthog.js.map +0 -1
  168. package/dist/project-fields.d.ts +0 -40
  169. package/dist/project-fields.d.ts.map +0 -1
  170. package/dist/project-fields.js +0 -79
  171. package/dist/project-fields.js.map +0 -1
  172. package/dist/prompt-consent.d.ts +0 -73
  173. package/dist/prompt-consent.d.ts.map +0 -1
  174. package/dist/prompt-consent.js +0 -191
  175. package/dist/prompt-consent.js.map +0 -1
  176. package/dist/prompt-stats.d.ts +0 -226
  177. package/dist/prompt-stats.d.ts.map +0 -1
  178. package/dist/prompt-stats.js +0 -561
  179. package/dist/prompt-stats.js.map +0 -1
  180. package/dist/referral.d.ts +0 -14
  181. package/dist/referral.d.ts.map +0 -1
  182. package/dist/referral.js +0 -28
  183. package/dist/referral.js.map +0 -1
  184. package/dist/registry.d.ts +0 -15
  185. package/dist/registry.d.ts.map +0 -1
  186. package/dist/registry.js +0 -180
  187. package/dist/registry.js.map +0 -1
  188. package/dist/resolve-command.d.ts +0 -20
  189. package/dist/resolve-command.d.ts.map +0 -1
  190. package/dist/resolve-command.js +0 -21
  191. package/dist/resolve-command.js.map +0 -1
  192. package/dist/scanners/antigravity.d.ts +0 -41
  193. package/dist/scanners/antigravity.d.ts.map +0 -1
  194. package/dist/scanners/antigravity.js +0 -210
  195. package/dist/scanners/antigravity.js.map +0 -1
  196. package/dist/scanners/github-copilot-telemetry.d.ts +0 -23
  197. package/dist/scanners/github-copilot-telemetry.d.ts.map +0 -1
  198. package/dist/scanners/github-copilot-telemetry.js +0 -220
  199. package/dist/scanners/github-copilot-telemetry.js.map +0 -1
  200. package/dist/scanners/github-copilot.d.ts +0 -68
  201. package/dist/scanners/github-copilot.d.ts.map +0 -1
  202. package/dist/scanners/github-copilot.js +0 -262
  203. package/dist/scanners/github-copilot.js.map +0 -1
  204. package/dist/scanners/incremental.d.ts +0 -282
  205. package/dist/scanners/incremental.d.ts.map +0 -1
  206. package/dist/scanners/incremental.js +0 -788
  207. package/dist/scanners/incremental.js.map +0 -1
  208. package/dist/scanners/index.d.ts +0 -116
  209. package/dist/scanners/index.d.ts.map +0 -1
  210. package/dist/scanners/index.js +0 -726
  211. package/dist/scanners/index.js.map +0 -1
  212. package/dist/scanners/sqlite.d.ts +0 -18
  213. package/dist/scanners/sqlite.d.ts.map +0 -1
  214. package/dist/scanners/sqlite.js +0 -63
  215. package/dist/scanners/sqlite.js.map +0 -1
  216. package/dist/scanners/util.d.ts +0 -112
  217. package/dist/scanners/util.d.ts.map +0 -1
  218. package/dist/scanners/util.js +0 -167
  219. package/dist/scanners/util.js.map +0 -1
  220. package/dist/session.d.ts +0 -106
  221. package/dist/session.d.ts.map +0 -1
  222. package/dist/session.js +0 -191
  223. package/dist/session.js.map +0 -1
  224. package/dist/share-card-colors.d.ts +0 -19
  225. package/dist/share-card-colors.d.ts.map +0 -1
  226. package/dist/share-card-colors.js +0 -36
  227. package/dist/share-card-colors.js.map +0 -1
  228. package/dist/share-card.d.ts +0 -31
  229. package/dist/share-card.d.ts.map +0 -1
  230. package/dist/share-card.js +0 -380
  231. package/dist/share-card.js.map +0 -1
  232. package/dist/share.d.ts +0 -17
  233. package/dist/share.d.ts.map +0 -1
  234. package/dist/share.js +0 -75
  235. package/dist/share.js.map +0 -1
  236. package/dist/sync.d.ts +0 -100
  237. package/dist/sync.d.ts.map +0 -1
  238. package/dist/sync.js +0 -304
  239. package/dist/sync.js.map +0 -1
  240. package/dist/ui.d.ts +0 -33
  241. package/dist/ui.d.ts.map +0 -1
  242. package/dist/ui.js +0 -127
  243. package/dist/ui.js.map +0 -1
  244. package/dist/utils/npmGlobal.d.ts +0 -30
  245. package/dist/utils/npmGlobal.d.ts.map +0 -1
  246. package/dist/utils/npmGlobal.js +0 -69
  247. package/dist/utils/npmGlobal.js.map +0 -1
  248. package/dist/utils/openBrowser.d.ts +0 -14
  249. package/dist/utils/openBrowser.d.ts.map +0 -1
  250. package/dist/utils/openBrowser.js +0 -47
  251. package/dist/utils/openBrowser.js.map +0 -1
  252. package/dist/utils/updateCheck.d.ts +0 -16
  253. package/dist/utils/updateCheck.d.ts.map +0 -1
  254. package/dist/utils/updateCheck.js +0 -111
  255. package/dist/utils/updateCheck.js.map +0 -1
  256. package/dist/utils/waitForEnter.d.ts +0 -12
  257. package/dist/utils/waitForEnter.d.ts.map +0 -1
  258. package/dist/utils/waitForEnter.js +0 -43
  259. package/dist/utils/waitForEnter.js.map +0 -1
@@ -1,665 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { realpathSync } from 'node:fs';
3
- import { appendFile, mkdir, readFile, rm, stat, writeFile, } from 'node:fs/promises';
4
- import { homedir, platform } from 'node:os';
5
- import { dirname, join } from 'node:path';
6
- import { loadConfig, updateConfig } from './config.js';
7
- import { getSessionPath } from './session.js';
8
- import { bold, dim } from './ui.js';
9
- // Sets up (and tears down) the two OS-native background jobs: a `hacklab sync
10
- // --tick` every minute (the incremental one — reads only what the AI tools
11
- // appended since the last run) and the full `hacklab sync --quiet` once a day
12
- // (the repair pass, which also re-bases the tick's state). launchd on macOS, a
13
- // systemd user timer on Linux, Task Scheduler on Windows, and printed
14
- // instructions when none of those fit. The scheduled command is resolved to an
15
- // absolute `node <entry>` pair so a scheduler running with a bare PATH
16
- // (cron/launchd/systemd often have almost none) can launch it without a
17
- // `hacklab` shim.
18
- const LAUNCHD_LABEL = 'so.hacklab.sync';
19
- const LAUNCHD_TICK_LABEL = 'so.hacklab.tick';
20
- const SYSTEMD_UNIT = 'hacklab-sync';
21
- const SYSTEMD_TICK_UNIT = 'hacklab-tick';
22
- const SCHTASKS_TASK = 'hacklab-sync';
23
- const SCHTASKS_TICK_TASK = 'hacklab-tick';
24
- /** Bump when the plist/unit/wrapper templates, the log path derivation, or the
25
- * way the command is resolved change, so machines with the old artifacts
26
- * reinstall instead of keeping a schedule this version would no longer write.
27
- * Part of the stored fingerprint.
28
- *
29
- * 2: the scheduled node path is now the version-stable one where there is one
30
- * (see stableNodePath) — every existing install points at whatever
31
- * `process.execPath` happened to be, so they all need re-arming. */
32
- const TEMPLATE_VERSION = 2;
33
- /** Run a command without ever rejecting: resolves the exit code (127 when the
34
- * binary can't even be spawned, e.g. `systemctl` absent). */
35
- function run(cmd, args, opts = {}) {
36
- return new Promise((resolve) => {
37
- const child = spawn(cmd, args, { stdio: 'ignore', ...opts });
38
- child.on('error', () => resolve(127));
39
- child.on('close', (code) => resolve(code ?? 0));
40
- });
41
- }
42
- /** The daily background job writes here; launchd/systemd also point stdout+stderr
43
- * at it. Lives next to the session file so it honors HACKLAB_SESSION_PATH. */
44
- export function syncLogPath() {
45
- return join(dirname(getSessionPath()), 'sync.log');
46
- }
47
- function pausedMarkerPath() {
48
- return join(dirname(getSessionPath()), 'sync-paused');
49
- }
50
- // The tick runs 1440 times a day, so the log has to be bounded even if every
51
- // one of those runs has something to say.
52
- export const SYNC_LOG_MAX_BYTES = 1_000_000;
53
- export const SYNC_LOG_KEEP_LINES = 200;
54
- /** Cut an oversized sync.log down to its last few hundred lines. Best-effort,
55
- * and a no-op while the log is small — which is the normal case. */
56
- export async function trimSyncLog(maxBytes = SYNC_LOG_MAX_BYTES, keepLines = SYNC_LOG_KEEP_LINES) {
57
- try {
58
- const path = syncLogPath();
59
- if ((await stat(path)).size <= maxBytes)
60
- return;
61
- const lines = (await readFile(path, 'utf8')).split('\n');
62
- const kept = lines.slice(-keepLines).join('\n');
63
- await writeFile(path, kept.endsWith('\n') ? kept : `${kept}\n`, 'utf8');
64
- }
65
- catch {
66
- // no log yet, or we can't rewrite it — either way, nothing to do
67
- }
68
- }
69
- /** Append a timestamped line to the background-sync log (best-effort). */
70
- export async function appendSyncLog(line) {
71
- try {
72
- await mkdir(dirname(syncLogPath()), { recursive: true });
73
- await appendFile(syncLogPath(), `${new Date().toISOString()} ${line}\n`);
74
- }
75
- catch {
76
- // logging must never break the job
77
- }
78
- }
79
- /** Record that the background sync can't continue (e.g. the session expired), so
80
- * the next interactive `hacklab` run can surface it. */
81
- export async function markSyncPaused(reason) {
82
- try {
83
- await mkdir(dirname(pausedMarkerPath()), { recursive: true });
84
- await writeFile(pausedMarkerPath(), reason, 'utf8');
85
- }
86
- catch {
87
- // best-effort
88
- }
89
- }
90
- /** The reason the background sync is paused, or null if it isn't. */
91
- export async function readSyncPaused() {
92
- try {
93
- const reason = (await readFile(pausedMarkerPath(), 'utf8')).trim();
94
- return reason.length > 0 ? reason : null;
95
- }
96
- catch {
97
- return null;
98
- }
99
- }
100
- export async function clearSyncPaused() {
101
- try {
102
- await rm(pausedMarkerPath(), { force: true });
103
- }
104
- catch {
105
- // best-effort
106
- }
107
- }
108
- /** realpath, or null when the path doesn't resolve (missing, dangling symlink). */
109
- function realOrNull(path) {
110
- try {
111
- return realpathSync(path);
112
- }
113
- catch {
114
- return null;
115
- }
116
- }
117
- /** Well-known absolute paths that keep pointing at the current node across an
118
- * upgrade. Windows has none to add: `process.execPath` there is already a
119
- * version-stable install location, not a per-version keg. */
120
- function stableNodeCandidates() {
121
- if (platform() === 'win32')
122
- return [];
123
- const voltaHome = process.env.VOLTA_HOME;
124
- const candidates = [
125
- '/opt/homebrew/bin/node',
126
- '/usr/local/bin/node',
127
- '/usr/bin/node',
128
- voltaHome ? join(voltaHome, 'bin', 'node') : null,
129
- join(homedir(), '.volta', 'bin', 'node'),
130
- ];
131
- return [...new Set(candidates.filter((c) => c !== null))];
132
- }
133
- /**
134
- * An absolute path to *this* node that survives the next upgrade of it.
135
- *
136
- * `process.execPath` under a package manager is version-scoped:
137
- * `/opt/homebrew/Cellar/node/26.5.0/bin/node`. `brew upgrade node` relinks
138
- * `/opt/homebrew/bin/node` to the new keg and moves the shared libraries with
139
- * it, leaving the old absolute path resolving to a binary that can no longer
140
- * start ("Library not loaded: …/libada.3.dylib"). A scheduler holding that path
141
- * dies silently, which is exactly what happened.
142
- *
143
- * So: resolve each well-known stable location and take the first that is
144
- * *literally the same binary* as the one running now — identity by resolved
145
- * real path, never by version string and never by trusting the symlink blindly.
146
- * No match (fnm/nvm/asdf shims are per-shell and version-scoped, so they never
147
- * match) falls back to `process.execPath`, which is what shipped before.
148
- *
149
- * It stays an absolute path either way, never a bare `node`: launchd, systemd
150
- * and Task Scheduler run with almost no PATH, which is the whole reason this
151
- * function returns a path at all. Don't "simplify" it to `node`.
152
- */
153
- export function stableNodePath() {
154
- const real = realOrNull(process.execPath);
155
- if (!real)
156
- return process.execPath;
157
- for (const candidate of stableNodeCandidates()) {
158
- if (realOrNull(candidate) === real)
159
- return candidate;
160
- }
161
- return process.execPath;
162
- }
163
- /** Absolute `node` + the CLI's own entry script, so the scheduler doesn't depend
164
- * on a `hacklab` bin being on its PATH. */
165
- export function resolveSyncCommand() {
166
- const argv1 = process.argv[1];
167
- let script = argv1 ?? 'hacklab';
168
- try {
169
- if (argv1)
170
- script = realpathSync(argv1);
171
- }
172
- catch {
173
- // argv1 not resolvable (unusual) — fall back to the raw path.
174
- }
175
- return { node: stableNodePath(), script };
176
- }
177
- /** Identity of what the jobs were installed with: the command they run plus the
178
- * template generation that produced them. Stored after a successful install so a
179
- * later run can tell an up-to-date schedule from one worth replacing. */
180
- export function syncCommandFingerprint(cmd) {
181
- // JSON, not concatenation: a path can contain any separator we'd pick.
182
- return JSON.stringify({
183
- v: TEMPLATE_VERSION,
184
- node: cmd.node,
185
- script: cmd.script,
186
- });
187
- }
188
- /** The command a stored fingerprint describes, or null when it's from an older
189
- * (or unrecognizable) format — which reads as "reinstall and re-record". */
190
- function parseFingerprint(fingerprint) {
191
- if (!fingerprint)
192
- return null;
193
- try {
194
- const parsed = JSON.parse(fingerprint);
195
- if (typeof parsed !== 'object' || parsed === null)
196
- return null;
197
- const { v, node, script } = parsed;
198
- if (v !== TEMPLATE_VERSION)
199
- return null;
200
- if (typeof node !== 'string' || typeof script !== 'string')
201
- return null;
202
- return { node, script };
203
- }
204
- catch {
205
- return null;
206
- }
207
- }
208
- async function exists(path) {
209
- try {
210
- await stat(path);
211
- return true;
212
- }
213
- catch {
214
- return false;
215
- }
216
- }
217
- /**
218
- * Whether the command the scheduler is already running still works — both halves
219
- * of it still on disk. A user who alternates node versions (nvm) or checkouts
220
- * flips the fingerprint back and forth without breaking anything, and tearing
221
- * the jobs down and rebuilding them on every alternation is worse than leaving a
222
- * working schedule alone.
223
- */
224
- async function recordedCommandUsable(record) {
225
- const recorded = parseFingerprint(record.command);
226
- if (!recorded)
227
- return false;
228
- return (await exists(recorded.node)) && (await exists(recorded.script));
229
- }
230
- /** Randomize the time so installs don't all hit the API at the same instant. */
231
- function randomTime() {
232
- return {
233
- hour: Math.floor(Math.random() * 24),
234
- minute: Math.floor(Math.random() * 60),
235
- };
236
- }
237
- function storedTime(record) {
238
- const { hour, minute } = record ?? {};
239
- const valid = (n, max) => typeof n === 'number' && Number.isInteger(n) && n >= 0 && n < max;
240
- return valid(hour, 24) && valid(minute, 60)
241
- ? { hour: hour, minute: minute }
242
- : null;
243
- }
244
- async function loadDailySyncRecord() {
245
- return (await loadConfig()).dailySync;
246
- }
247
- /** Remember this install. False when the config couldn't be written — the jobs
248
- * are scheduled either way, but nothing will know that next time. */
249
- async function saveDailySyncRecord(record) {
250
- return updateConfig((config) => ({ ...config, dailySync: record }));
251
- }
252
- async function clearDailySyncRecord() {
253
- // No record means nothing to clear: `logout` and `daemon off` shouldn't
254
- // rewrite config.json for every user who never had a schedule.
255
- await updateConfig(({ dailySync, ...rest }) => (dailySync ? rest : null));
256
- }
257
- function xmlEscape(s) {
258
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
259
- }
260
- const pad2 = (n) => String(n).padStart(2, '0');
261
- /** The plist skeleton both jobs share — same command shape, different schedule. */
262
- function launchdDoc(label, cmd, mode, schedule, logPath) {
263
- const args = [cmd.node, cmd.script, 'sync', mode]
264
- .map((a) => ` <string>${xmlEscape(a)}</string>`)
265
- .join('\n');
266
- return `<?xml version="1.0" encoding="UTF-8"?>
267
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
268
- <plist version="1.0">
269
- <dict>
270
- <key>Label</key>
271
- <string>${label}</string>
272
- <key>ProgramArguments</key>
273
- <array>
274
- ${args}
275
- </array>
276
- ${schedule}
277
- <key>StandardOutPath</key>
278
- <string>${xmlEscape(logPath)}</string>
279
- <key>StandardErrorPath</key>
280
- <string>${xmlEscape(logPath)}</string>
281
- <key>ProcessType</key>
282
- <string>Background</string>
283
- </dict>
284
- </plist>
285
- `;
286
- }
287
- export function launchdPlist(cmd, time, logPath) {
288
- return launchdDoc(LAUNCHD_LABEL, cmd, '--quiet', ` <key>StartCalendarInterval</key>
289
- <dict>
290
- <key>Hour</key>
291
- <integer>${time.hour}</integer>
292
- <key>Minute</key>
293
- <integer>${time.minute}</integer>
294
- </dict>`, logPath);
295
- }
296
- /** The minutely tick. StartInterval (not a calendar interval) so launchd just
297
- * re-runs it every 60s; a tick with nothing to report exits without a request. */
298
- export function launchdTickPlist(cmd, logPath) {
299
- return launchdDoc(LAUNCHD_TICK_LABEL, cmd, '--tick', ` <key>StartInterval</key>
300
- <integer>60</integer>`, logPath);
301
- }
302
- export function systemdService(cmd) {
303
- // Quote node + script so paths with spaces survive systemd's parser.
304
- return `[Unit]
305
- Description=hacklab daily token sync
306
-
307
- [Service]
308
- Type=oneshot
309
- ExecStart="${cmd.node}" "${cmd.script}" sync --quiet
310
- `;
311
- }
312
- export function systemdTimer() {
313
- // OnCalendar=daily with a randomized delay spreads load across users; Persistent
314
- // catches up a run missed while the machine was off.
315
- return `[Unit]
316
- Description=hacklab daily token sync
317
-
318
- [Timer]
319
- OnCalendar=daily
320
- Persistent=true
321
- RandomizedDelaySec=3600
322
-
323
- [Install]
324
- WantedBy=timers.target
325
- `;
326
- }
327
- export function systemdTickService(cmd) {
328
- return `[Unit]
329
- Description=hacklab token tick
330
-
331
- [Service]
332
- Type=oneshot
333
- ExecStart="${cmd.node}" "${cmd.script}" sync --tick
334
- `;
335
- }
336
- export function systemdTickTimer() {
337
- // OnUnitActiveSec re-arms a minute after each run finishes (so a slow tick
338
- // can't stack up), OnBootSec gives the desktop a moment to settle first, and
339
- // AccuracySec lets systemd batch the wakeup with others — a minutely timer is
340
- // otherwise a needless drain on a laptop.
341
- return `[Unit]
342
- Description=hacklab token tick
343
-
344
- [Timer]
345
- OnBootSec=2min
346
- OnUnitActiveSec=1min
347
- AccuracySec=30s
348
-
349
- [Install]
350
- WantedBy=timers.target
351
- `;
352
- }
353
- /** Where the Windows scheduled task's wrapper batch file lives. Next to the
354
- * session file so it honors HACKLAB_SESSION_PATH, mirroring syncLogPath(). */
355
- export function syncWrapperPath() {
356
- return join(dirname(getSessionPath()), 'hacklab-sync.cmd');
357
- }
358
- /** The tick's own wrapper — same pattern, its own file so the two tasks can be
359
- * created and deleted independently. */
360
- export function tickWrapperPath() {
361
- return join(dirname(getSessionPath()), 'hacklab-tick.cmd');
362
- }
363
- /** The batch wrapper Task Scheduler runs. schtasks' `/TR` chokes on the nested
364
- * quotes a full `"node" "script" …` command needs, so we point the task at this
365
- * one-line .cmd instead and do the quoting here, where plain-batch rules apply.
366
- * Output is redirected to the same sync.log the job also appends to, so a crash
367
- * before the CLI's own logging still leaves a trace. */
368
- export function schtasksWrapper(cmd, logPath) {
369
- return `@echo off\r\n"${cmd.node}" "${cmd.script}" sync --quiet >> "${logPath}" 2>&1\r\n`;
370
- }
371
- /** Same wrapper for the minutely tick, logging to the same sync.log. */
372
- export function schtasksTickWrapper(cmd, logPath) {
373
- return `@echo off\r\n"${cmd.node}" "${cmd.script}" sync --tick >> "${logPath}" 2>&1\r\n`;
374
- }
375
- /** Args for `schtasks /Create`. The `/TR` value is a single pre-quoted path;
376
- * spawn must run with windowsVerbatimArguments so Node doesn't re-escape it. */
377
- export function schtasksCreateArgs(wrapperPath, time) {
378
- return [
379
- '/Create',
380
- '/SC',
381
- 'DAILY',
382
- '/TN',
383
- SCHTASKS_TASK,
384
- '/TR',
385
- `"${wrapperPath}"`,
386
- '/ST',
387
- `${pad2(time.hour)}:${pad2(time.minute)}`,
388
- '/F',
389
- ];
390
- }
391
- /** Args for `schtasks /Create` for the tick: every minute, forever. */
392
- export function schtasksTickCreateArgs(wrapperPath) {
393
- return [
394
- '/Create',
395
- '/SC',
396
- 'MINUTE',
397
- '/MO',
398
- '1',
399
- '/TN',
400
- SCHTASKS_TICK_TASK,
401
- '/TR',
402
- `"${wrapperPath}"`,
403
- '/F',
404
- ];
405
- }
406
- // The fallback for "Linux without a systemd user manager" and "an OS we don't
407
- // auto-schedule on" alike, so it names no specific mechanism it tried.
408
- export function manualInstructions(cmd) {
409
- const bin = `"${cmd.node}" "${cmd.script}"`;
410
- return [
411
- { cadence: 'every minute', command: `${bin} sync --tick` },
412
- { cadence: 'once a day', command: `${bin} sync --quiet` },
413
- ];
414
- }
415
- /** Render a ManualSchedule as ready-to-print lines: a dim explainer, then each
416
- * job as a dim cadence label above its bold, copy-pasteable command. Blank
417
- * lines separate the two commands so neither reads as a wrapped continuation of
418
- * the other. */
419
- export function formatManualSchedule(schedule) {
420
- const lines = [
421
- dim("Schedule these with cron, a systemd timer, or your init system's scheduler:"),
422
- ];
423
- for (const { cadence, command } of schedule) {
424
- lines.push('', dim(`${cadence}:`), bold(command));
425
- }
426
- return lines;
427
- }
428
- function launchAgentPath(label) {
429
- return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
430
- }
431
- /** Write a plist and (re)load it. Unload first so a re-run replaces the agent
432
- * rather than stacking a second copy. */
433
- async function loadAgent(path, contents) {
434
- await mkdir(dirname(path), { recursive: true });
435
- await writeFile(path, contents, 'utf8');
436
- await run('launchctl', ['unload', path]);
437
- return run('launchctl', ['load', '-w', path]);
438
- }
439
- async function installLaunchd(cmd, logPath, time) {
440
- const plistPath = launchAgentPath(LAUNCHD_LABEL);
441
- const loaded = await loadAgent(plistPath, launchdPlist(cmd, time, logPath));
442
- await loadAgent(launchAgentPath(LAUNCHD_TICK_LABEL), launchdTickPlist(cmd, logPath));
443
- // A plist in ~/Library/LaunchAgents also loads at next login, so even if
444
- // `load` fails now the agent activates then — report that honestly rather
445
- // than claiming it's already running.
446
- return {
447
- ok: true,
448
- mechanism: 'launchd',
449
- tick: true,
450
- detail: loaded === 0
451
- ? `tick every minute, full sync daily around ${pad2(time.hour)}:${pad2(time.minute)} (${plistPath})`
452
- : `installed (${plistPath}) — activates at next login`,
453
- };
454
- }
455
- async function installSystemd(cmd) {
456
- // systemd captures stdout/stderr in the journal (journalctl --user), so unlike
457
- // launchd there's no log file to point at here.
458
- // Probe the user manager before writing anything, so a box without systemd
459
- // (or without a user D-Bus) gets clean manual instructions and no litter.
460
- if ((await run('systemctl', ['--user', 'daemon-reload'])) !== 0) {
461
- return {
462
- ok: false,
463
- mechanism: 'manual',
464
- instructions: manualInstructions(cmd),
465
- };
466
- }
467
- const dir = join(homedir(), '.config', 'systemd', 'user');
468
- await mkdir(dir, { recursive: true });
469
- await writeFile(join(dir, `${SYSTEMD_UNIT}.service`), systemdService(cmd), 'utf8');
470
- await writeFile(join(dir, `${SYSTEMD_UNIT}.timer`), systemdTimer(), 'utf8');
471
- await writeFile(join(dir, `${SYSTEMD_TICK_UNIT}.service`), systemdTickService(cmd), 'utf8');
472
- await writeFile(join(dir, `${SYSTEMD_TICK_UNIT}.timer`), systemdTickTimer(), 'utf8');
473
- await run('systemctl', ['--user', 'daemon-reload']);
474
- await run('systemctl', ['--user', 'enable', '--now', `${SYSTEMD_UNIT}.timer`]);
475
- await run('systemctl', [
476
- '--user',
477
- 'enable',
478
- '--now',
479
- `${SYSTEMD_TICK_UNIT}.timer`,
480
- ]);
481
- return {
482
- ok: true,
483
- mechanism: 'systemd',
484
- tick: true,
485
- detail: `systemd user timers (${SYSTEMD_TICK_UNIT}.timer every minute, ${SYSTEMD_UNIT}.timer daily); logs in journalctl --user -u ${SYSTEMD_UNIT}. To keep them running while logged out: loginctl enable-linger`,
486
- };
487
- }
488
- async function installSchtasks(cmd, time) {
489
- const wrapperPath = syncWrapperPath();
490
- await mkdir(dirname(wrapperPath), { recursive: true });
491
- await writeFile(wrapperPath, schtasksWrapper(cmd, syncLogPath()), 'utf8');
492
- await writeFile(tickWrapperPath(), schtasksTickWrapper(cmd, syncLogPath()), 'utf8');
493
- // /F overwrites an existing task (idempotent re-install). Verbatim args so the
494
- // pre-quoted /TR path reaches schtasks intact. A non-zero exit (e.g. schtasks
495
- // absent, or policy blocks task creation) falls back to manual instructions.
496
- const created = await run('schtasks', schtasksCreateArgs(wrapperPath, time), {
497
- windowsVerbatimArguments: true,
498
- });
499
- if (created !== 0) {
500
- return {
501
- ok: false,
502
- mechanism: 'manual',
503
- instructions: manualInstructions(cmd),
504
- };
505
- }
506
- // The daily job is the one that must exist; a refused minutely task (some
507
- // policies cap task frequency) costs freshness, not the streak.
508
- const tick = await run('schtasks', schtasksTickCreateArgs(tickWrapperPath()), { windowsVerbatimArguments: true });
509
- return {
510
- ok: true,
511
- mechanism: 'schtasks',
512
- tick: tick === 0,
513
- detail: `${tick === 0 ? 'tick every minute, ' : ''}full sync daily around ${pad2(time.hour)}:${pad2(time.minute)} (Task Scheduler task "${SCHTASKS_TASK}")`,
514
- };
515
- }
516
- /**
517
- * True when the OS scheduler has the background jobs. A half-install (say a
518
- * plist that got deleted) reads as not installed, so the next run repairs it.
519
- *
520
- * `expectTick: false` drops the tick from that expectation, for the one machine
521
- * that legitimately has none: a Windows box whose policy refused the minutely
522
- * task. Without it such a box would read as half-installed forever and reinstall
523
- * on every scan. Callers that don't know either way get the strict check.
524
- */
525
- export async function dailySyncInstalled({ expectTick = true, } = {}) {
526
- const os = platform();
527
- const tickToo = (daily, tick) => expectTick ? [daily, tick] : [daily];
528
- try {
529
- if (os === 'darwin') {
530
- for (const label of tickToo(LAUNCHD_LABEL, LAUNCHD_TICK_LABEL)) {
531
- await stat(launchAgentPath(label));
532
- }
533
- return true;
534
- }
535
- if (os === 'linux') {
536
- const dir = join(homedir(), '.config', 'systemd', 'user');
537
- for (const unit of tickToo(SYSTEMD_UNIT, SYSTEMD_TICK_UNIT)) {
538
- await stat(join(dir, `${unit}.timer`));
539
- }
540
- return true;
541
- }
542
- if (os === 'win32') {
543
- for (const task of tickToo(SCHTASKS_TASK, SCHTASKS_TICK_TASK)) {
544
- if ((await run('schtasks', ['/Query', '/TN', task])) !== 0)
545
- return false;
546
- }
547
- return true;
548
- }
549
- }
550
- catch {
551
- // missing file / scheduler
552
- }
553
- return false;
554
- }
555
- /**
556
- * What a routine run (e.g. `scan`) should do about the background jobs:
557
- *
558
- * 'missing' — no jobs, or only half of them: install, and say so.
559
- * 'stale' — jobs exist but what they run is broken or unknown: reinstall
560
- * quietly.
561
- * 'current' — jobs exist and still work: do nothing.
562
- *
563
- * "Still work" is deliberately weaker than "match what this process would
564
- * install": someone alternating node versions or checkouts flips the fingerprint
565
- * on every run while both schedules remain perfectly runnable, and rebuilding
566
- * the jobs each way round would be a permanent ping-pong. Only a command that
567
- * has actually gone missing (or a record we can't read, which includes installs
568
- * predating the fingerprint) earns a reinstall.
569
- */
570
- export async function dailySyncState() {
571
- const record = await loadDailySyncRecord();
572
- // Only a record that saw the tick get refused excuses a missing tick job.
573
- const expectTick = record?.tick !== false;
574
- if (!(await dailySyncInstalled({ expectTick })))
575
- return 'missing';
576
- if (!record?.command)
577
- return 'stale';
578
- if (record.command === syncCommandFingerprint(resolveSyncCommand())) {
579
- return 'current';
580
- }
581
- return (await recordedCommandUsable(record)) ? 'current' : 'stale';
582
- }
583
- /** Install (or refresh) both background jobs (tick + daily) for the current OS. */
584
- export async function installDailySync() {
585
- const cmd = resolveSyncCommand();
586
- try {
587
- await mkdir(dirname(syncLogPath()), { recursive: true }).catch(() => undefined);
588
- // Reuse the slot we picked the first time: re-rolling it on every reinstall
589
- // would keep moving the user's daily sync around the clock.
590
- const time = storedTime(await loadDailySyncRecord()) ?? randomTime();
591
- const os = platform();
592
- let result;
593
- if (os === 'darwin') {
594
- result = await installLaunchd(cmd, syncLogPath(), time);
595
- }
596
- else if (os === 'linux') {
597
- // systemd does its own load-spreading (RandomizedDelaySec), so `time` is
598
- // recorded but unused there.
599
- result = await installSystemd(cmd);
600
- }
601
- else if (os === 'win32') {
602
- result = await installSchtasks(cmd, time);
603
- }
604
- else {
605
- return {
606
- ok: false,
607
- mechanism: 'unsupported',
608
- instructions: manualInstructions(cmd),
609
- };
610
- }
611
- if (!result.ok)
612
- return result;
613
- const recorded = await saveDailySyncRecord({
614
- command: syncCommandFingerprint(cmd),
615
- ...time,
616
- tick: result.tick,
617
- });
618
- return { ...result, recorded };
619
- }
620
- catch {
621
- // A failed write (permissions, read-only home, …) must not crash the command
622
- // ritual — fall back to printable instructions.
623
- return {
624
- ok: false,
625
- mechanism: 'manual',
626
- instructions: manualInstructions(cmd),
627
- };
628
- }
629
- }
630
- /** Remove both background jobs. Best-effort and never throws, so it's safe to
631
- * call from `logout` whether or not they were ever installed. */
632
- export async function uninstallDailySync() {
633
- const os = platform();
634
- try {
635
- if (os === 'darwin') {
636
- for (const label of [LAUNCHD_LABEL, LAUNCHD_TICK_LABEL]) {
637
- const plistPath = launchAgentPath(label);
638
- await run('launchctl', ['unload', plistPath]);
639
- await rm(plistPath, { force: true });
640
- }
641
- }
642
- else if (os === 'linux') {
643
- const dir = join(homedir(), '.config', 'systemd', 'user');
644
- for (const unit of [SYSTEMD_UNIT, SYSTEMD_TICK_UNIT]) {
645
- await run('systemctl', ['--user', 'disable', '--now', `${unit}.timer`]);
646
- await rm(join(dir, `${unit}.timer`), { force: true });
647
- await rm(join(dir, `${unit}.service`), { force: true });
648
- }
649
- await run('systemctl', ['--user', 'daemon-reload']);
650
- }
651
- else if (os === 'win32') {
652
- await run('schtasks', ['/Delete', '/TN', SCHTASKS_TASK, '/F']);
653
- await run('schtasks', ['/Delete', '/TN', SCHTASKS_TICK_TASK, '/F']);
654
- await rm(syncWrapperPath(), { force: true });
655
- await rm(tickWrapperPath(), { force: true });
656
- }
657
- }
658
- catch {
659
- // best-effort
660
- }
661
- // Forget the command and the slot too, so a fresh install after a re-join
662
- // picks a new random time rather than resurrecting the old one.
663
- await clearDailySyncRecord();
664
- }
665
- //# sourceMappingURL=daily-sync.js.map