looply-cli 0.5.7 → 0.5.9

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 (84) hide show
  1. package/dist/.src-hash +1 -1
  2. package/dist/bin.d.ts.map +1 -1
  3. package/dist/bin.js +9 -1
  4. package/dist/bin.js.map +1 -1
  5. package/dist/branch-delivery-wiring.d.ts +41 -0
  6. package/dist/branch-delivery-wiring.d.ts.map +1 -0
  7. package/dist/branch-delivery-wiring.js +85 -0
  8. package/dist/branch-delivery-wiring.js.map +1 -0
  9. package/dist/branch-delivery.d.ts +275 -0
  10. package/dist/branch-delivery.d.ts.map +1 -0
  11. package/dist/branch-delivery.js +659 -0
  12. package/dist/branch-delivery.js.map +1 -0
  13. package/dist/config.d.ts +20 -1
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +24 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/coordinator-state.d.ts.map +1 -1
  18. package/dist/coordinator-state.js +28 -18
  19. package/dist/coordinator-state.js.map +1 -1
  20. package/dist/daemon-cmd.d.ts.map +1 -1
  21. package/dist/daemon-cmd.js +64 -5
  22. package/dist/daemon-cmd.js.map +1 -1
  23. package/dist/daemon-lock.d.ts +65 -2
  24. package/dist/daemon-lock.d.ts.map +1 -1
  25. package/dist/daemon-lock.js +149 -5
  26. package/dist/daemon-lock.js.map +1 -1
  27. package/dist/daemon.d.ts +12 -1
  28. package/dist/daemon.d.ts.map +1 -1
  29. package/dist/daemon.js +23 -3
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/deliver-cmd.d.ts +41 -0
  32. package/dist/deliver-cmd.d.ts.map +1 -0
  33. package/dist/deliver-cmd.js +189 -0
  34. package/dist/deliver-cmd.js.map +1 -0
  35. package/dist/delivery-rules.d.ts +71 -0
  36. package/dist/delivery-rules.d.ts.map +1 -0
  37. package/dist/delivery-rules.js +186 -0
  38. package/dist/delivery-rules.js.map +1 -0
  39. package/dist/delivery-settings-cache.d.ts +35 -0
  40. package/dist/delivery-settings-cache.d.ts.map +1 -0
  41. package/dist/delivery-settings-cache.js +119 -0
  42. package/dist/delivery-settings-cache.js.map +1 -0
  43. package/dist/github-api.d.ts +99 -0
  44. package/dist/github-api.d.ts.map +1 -0
  45. package/dist/github-api.js +226 -0
  46. package/dist/github-api.js.map +1 -0
  47. package/dist/goal-runs.d.ts +2 -2
  48. package/dist/goal-runs.d.ts.map +1 -1
  49. package/dist/goal-runs.js +13 -11
  50. package/dist/goal-runs.js.map +1 -1
  51. package/dist/machine-settings.d.ts +188 -0
  52. package/dist/machine-settings.d.ts.map +1 -0
  53. package/dist/machine-settings.js +658 -0
  54. package/dist/machine-settings.js.map +1 -0
  55. package/dist/project-terminal.d.ts +6 -0
  56. package/dist/project-terminal.d.ts.map +1 -1
  57. package/dist/project-terminal.js +3 -0
  58. package/dist/project-terminal.js.map +1 -1
  59. package/dist/run-ending-locator.d.ts.map +1 -1
  60. package/dist/run-ending-locator.js +5 -3
  61. package/dist/run-ending-locator.js.map +1 -1
  62. package/dist/serve-cmd.d.ts +8 -0
  63. package/dist/serve-cmd.d.ts.map +1 -1
  64. package/dist/serve-cmd.js +12 -0
  65. package/dist/serve-cmd.js.map +1 -1
  66. package/dist/server-client.d.ts +29 -2
  67. package/dist/server-client.d.ts.map +1 -1
  68. package/dist/server-client.js +39 -2
  69. package/dist/server-client.js.map +1 -1
  70. package/dist/stale-run-sweep.js +11 -10
  71. package/dist/stale-run-sweep.js.map +1 -1
  72. package/dist/untrusted-exec.d.ts +11 -0
  73. package/dist/untrusted-exec.d.ts.map +1 -1
  74. package/dist/untrusted-exec.js +1 -0
  75. package/dist/untrusted-exec.js.map +1 -1
  76. package/dist/workspace-provisioner.d.ts +8 -0
  77. package/dist/workspace-provisioner.d.ts.map +1 -1
  78. package/dist/workspace-provisioner.js +18 -0
  79. package/dist/workspace-provisioner.js.map +1 -1
  80. package/dist/workspace.d.ts +47 -13
  81. package/dist/workspace.d.ts.map +1 -1
  82. package/dist/workspace.js +121 -94
  83. package/dist/workspace.js.map +1 -1
  84. package/package.json +2 -2
@@ -0,0 +1,658 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.defaultWorkspacesBase = exports.USAGE_MAX_AGE_MS = exports.WORKSPACES_DIR = exports.CHECKOUTS_USAGE_FILE = exports.WORKSPACES_BASES_FILE = exports.REMOTE_MACHINE_SETTINGS_FILE = exports.MACHINE_SETTINGS_FILE = exports.DEFAULT_MIN_FREE_GB = void 0;
4
+ exports.readLocalMachineSettings = readLocalMachineSettings;
5
+ exports.readRemoteMachineSettings = readRemoteMachineSettings;
6
+ exports.writeRemoteMachineSettings = writeRemoteMachineSettings;
7
+ exports.nodeDriveKind = nodeDriveKind;
8
+ exports.nodeProbeWritable = nodeProbeWritable;
9
+ exports.checkoutFolderProblem = checkoutFolderProblem;
10
+ exports.effectiveWorkspaces = effectiveWorkspaces;
11
+ exports.rememberWorkspacesBase = rememberWorkspacesBase;
12
+ exports.knownWorkspacesBases = knownWorkspacesBases;
13
+ exports.freeGbAt = freeGbAt;
14
+ exports.bytesUnder = bytesUnder;
15
+ exports.measureCheckouts = measureCheckouts;
16
+ exports.readCheckoutsUsage = readCheckoutsUsage;
17
+ exports.refreshCheckoutsUsage = refreshCheckoutsUsage;
18
+ exports.checkoutRoomProblem = checkoutRoomProblem;
19
+ exports.workspacesReport = workspacesReport;
20
+ exports.makeMachineSettingsPull = makeMachineSettingsPull;
21
+ const node_child_process_1 = require("node:child_process");
22
+ const node_fs_1 = require("node:fs");
23
+ const promises_1 = require("node:fs/promises");
24
+ const node_os_1 = require("node:os");
25
+ const node_path_1 = require("node:path");
26
+ const core_1 = require("@looply/core");
27
+ Object.defineProperty(exports, "DEFAULT_MIN_FREE_GB", { enumerable: true, get: function () { return core_1.DEFAULT_MIN_FREE_GB; } });
28
+ const server_client_1 = require("./server-client");
29
+ const untrusted_exec_1 = require("./untrusted-exec");
30
+ exports.MACHINE_SETTINGS_FILE = 'machine-settings.json';
31
+ exports.REMOTE_MACHINE_SETTINGS_FILE = 'machine-settings-remote.json';
32
+ exports.WORKSPACES_BASES_FILE = 'workspaces-bases.json';
33
+ exports.CHECKOUTS_USAGE_FILE = 'checkouts-usage.json';
34
+ exports.WORKSPACES_DIR = 'workspaces';
35
+ /** How long a usage measurement is trusted before it is taken again. */
36
+ exports.USAGE_MAX_AGE_MS = 10 * 60_000;
37
+ /** How long a measurement in progress keeps other daemons from starting their own. */
38
+ const USAGE_CLAIM_MS = 5 * 60_000;
39
+ /** A refused remote folder is looked at again this often, so fixing the drive heals it. */
40
+ const REFUSED_RECHECK_MS = 5 * 60_000;
41
+ const GB = 1e9;
42
+ const looplyHome = (home) => (0, node_path_1.join)(home, '.looply');
43
+ const defaultWorkspacesBase = (home = (0, node_os_1.homedir)()) => (0, node_path_1.join)(looplyHome(home), exports.WORKSPACES_DIR);
44
+ exports.defaultWorkspacesBase = defaultWorkspacesBase;
45
+ /** Atomic: a reader never sees half a file, and many daemons may write the same answer. */
46
+ function writeJson(file, value) {
47
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(file), { recursive: true });
48
+ const tmp = `${file}.${process.pid}.${Math.floor(Math.random() * 1e9).toString(36)}.tmp`;
49
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
50
+ try {
51
+ (0, node_fs_1.renameSync)(tmp, file);
52
+ }
53
+ catch (e) {
54
+ try {
55
+ (0, node_fs_1.unlinkSync)(tmp);
56
+ }
57
+ catch {
58
+ /* already gone */
59
+ }
60
+ throw e;
61
+ }
62
+ }
63
+ function readJson(file) {
64
+ try {
65
+ return JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * `~/.looply/machine-settings.json`, checked. Absent is `{}`.
73
+ *
74
+ * ⚠️ AN UNUSABLE LOCAL SETTING THROWS, as it did in 0.5.7: falling back to the
75
+ * home drive silently is the exact outcome the setting exists to prevent, and a
76
+ * person wrote this file on this machine and can fix it.
77
+ */
78
+ function readLocalMachineSettings(home = (0, node_os_1.homedir)()) {
79
+ const file = (0, node_path_1.join)(looplyHome(home), exports.MACHINE_SETTINGS_FILE);
80
+ let raw;
81
+ try {
82
+ raw = (0, node_fs_1.readFileSync)(file, 'utf8');
83
+ }
84
+ catch (e) {
85
+ if (e.code === 'ENOENT')
86
+ return {};
87
+ throw e;
88
+ }
89
+ let o;
90
+ try {
91
+ const parsed = JSON.parse(raw);
92
+ o = typeof parsed === 'object' && parsed !== null ? parsed : {};
93
+ }
94
+ catch {
95
+ throw new Error(`${file} is not valid JSON, so where checkouts go is unknown`);
96
+ }
97
+ const out = {};
98
+ if (o['workspacesRoot'] !== undefined) {
99
+ const v = o['workspacesRoot'];
100
+ if (typeof v !== 'string' || !(0, node_path_1.isAbsolute)(v))
101
+ throw new Error(`${file}: "workspacesRoot" must be an absolute path`);
102
+ out.workspacesRoot = v;
103
+ }
104
+ if (o['minFreeGb'] !== undefined) {
105
+ const v = o['minFreeGb'];
106
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
107
+ throw new Error(`${file}: "minFreeGb" must be a number of GB, 0 or more`);
108
+ }
109
+ out.minFreeGb = v;
110
+ }
111
+ if (o['checkoutsCapGb'] !== undefined && o['checkoutsCapGb'] !== null) {
112
+ const v = o['checkoutsCapGb'];
113
+ if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) {
114
+ throw new Error(`${file}: "checkoutsCapGb" must be a number of GB above 0`);
115
+ }
116
+ out.checkoutsCapGb = v;
117
+ }
118
+ return out;
119
+ }
120
+ function readRemoteMachineSettings(home = (0, node_os_1.homedir)()) {
121
+ const raw = readJson((0, node_path_1.join)(looplyHome(home), exports.REMOTE_MACHINE_SETTINGS_FILE));
122
+ if (typeof raw !== 'object' || raw === null)
123
+ return null;
124
+ const r = raw;
125
+ // Re-validated on every read with the server's own rule, so a hand-edited
126
+ // or corrupted file cannot point checkouts somewhere the server would refuse.
127
+ const v = (0, core_1.validateMachineSettings)(r['settings']);
128
+ if (!v.ok)
129
+ return null;
130
+ const inEffect = typeof r['inEffect'] === 'string' && (0, core_1.folderShapeProblem)(r['inEffect']) === null ? r['inEffect'] : null;
131
+ const vr = r['verdict'];
132
+ const verdict = vr && typeof vr === 'object' && typeof vr['value'] === 'string' && typeof vr['ok'] === 'boolean'
133
+ ? {
134
+ value: vr['value'],
135
+ ok: vr['ok'],
136
+ because: typeof vr['because'] === 'string' ? vr['because'] : null,
137
+ checkedAt: typeof vr['checkedAt'] === 'string' ? vr['checkedAt'] : '',
138
+ }
139
+ : null;
140
+ return {
141
+ settings: v.settings,
142
+ updatedAt: typeof r['updatedAt'] === 'string' ? r['updatedAt'] : null,
143
+ fetchedAt: typeof r['fetchedAt'] === 'string' ? r['fetchedAt'] : '',
144
+ inEffect,
145
+ verdict,
146
+ };
147
+ }
148
+ function writeRemoteMachineSettings(home, value) {
149
+ writeJson((0, node_path_1.join)(looplyHome(home), exports.REMOTE_MACHINE_SETTINGS_FILE), value);
150
+ }
151
+ /**
152
+ * What kind of drive `path` is on. Windows only asks; elsewhere every local
153
+ * path is answered `fixed` (a POSIX machine has no drive letters to judge).
154
+ */
155
+ function nodeDriveKind(path, platform = process.platform) {
156
+ if (platform !== 'win32')
157
+ return 'fixed';
158
+ const letter = /^([A-Za-z]):/.exec(path)?.[1];
159
+ if (letter === undefined)
160
+ return 'unknown';
161
+ try {
162
+ // By absolute path: a bare name would let the working directory supply it.
163
+ const out = (0, node_child_process_1.execFileSync)((0, untrusted_exec_1.powershellPath)(), [
164
+ '-NoProfile',
165
+ '-NonInteractive',
166
+ '-Command',
167
+ `$d = New-Object System.IO.DriveInfo '${letter.toUpperCase()}'; if ($d.IsReady -or $d.DriveType -ne 'NoRootDirectory') { $d.DriveType } else { 'Missing' }`,
168
+ ], { encoding: 'utf8', timeout: 15_000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim().toLowerCase();
169
+ if (out === 'fixed')
170
+ return 'fixed';
171
+ if (out === 'removable')
172
+ return 'removable';
173
+ if (out === 'network')
174
+ return 'network';
175
+ if (out === 'cdrom')
176
+ return 'cdrom';
177
+ if (out === 'ram')
178
+ return 'ram';
179
+ if (out === 'missing' || out === 'norootdirectory')
180
+ return 'missing';
181
+ return 'unknown';
182
+ }
183
+ catch {
184
+ return 'unknown';
185
+ }
186
+ }
187
+ function nodeProbeWritable(path) {
188
+ (0, node_fs_1.mkdirSync)(path, { recursive: true });
189
+ const probe = (0, node_path_1.join)(path, `.looply-write-check-${process.pid}`);
190
+ (0, node_fs_1.writeFileSync)(probe, 'ok', 'utf8');
191
+ (0, node_fs_1.unlinkSync)(probe);
192
+ }
193
+ /** Case-folded and resolved on Windows, so `d:\X` and `D:\x\` compare equal. */
194
+ function norm(p, platform) {
195
+ const path = platform === 'win32' ? node_path_1.win32 : node_path_1.posix;
196
+ let r = path.resolve(p);
197
+ if (r.length > path.parse(r).root.length)
198
+ r = r.replace(/[\\/]+$/, '');
199
+ return platform === 'win32' ? r.toLowerCase() : r;
200
+ }
201
+ function within(parent, child, platform) {
202
+ const sep = platform === 'win32' ? '\\' : '/';
203
+ const p = norm(parent, platform);
204
+ const c = norm(child, platform);
205
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
206
+ }
207
+ /** Folders that belong to the operating system or to programs, never to checkouts. */
208
+ function systemFolders(platform, env, home) {
209
+ if (platform === 'win32') {
210
+ const sysDrive = (env['SystemDrive'] ?? 'C:') + '\\';
211
+ return [
212
+ env['SystemRoot'] ?? env['windir'] ?? node_path_1.win32.join(sysDrive, 'Windows'),
213
+ env['ProgramFiles'] ?? node_path_1.win32.join(sysDrive, 'Program Files'),
214
+ env['ProgramFiles(x86)'] ?? node_path_1.win32.join(sysDrive, 'Program Files (x86)'),
215
+ env['ProgramW6432'] ?? node_path_1.win32.join(sysDrive, 'Program Files'),
216
+ env['ProgramData'] ?? node_path_1.win32.join(sysDrive, 'ProgramData'),
217
+ node_path_1.win32.join(sysDrive, '$Recycle.Bin'),
218
+ node_path_1.win32.join(sysDrive, 'System Volume Information'),
219
+ node_path_1.win32.join(sysDrive, 'Recovery'),
220
+ node_path_1.win32.join(home, 'AppData'),
221
+ ];
222
+ }
223
+ return ['/bin', '/boot', '/dev', '/etc', '/lib', '/lib64', '/proc', '/sbin', '/sys', '/usr', '/var', '/System', '/Library', '/Applications', '/private'];
224
+ }
225
+ /**
226
+ * Why `folder` may not hold this machine's checkouts, or null when it may.
227
+ * The answer is the sentence the console and the ticket show.
228
+ */
229
+ function checkoutFolderProblem(folder, deps) {
230
+ const platform = deps.platform ?? process.platform;
231
+ const env = deps.env ?? process.env;
232
+ const path = platform === 'win32' ? node_path_1.win32 : node_path_1.posix;
233
+ const shape = (0, core_1.folderShapeProblem)(folder);
234
+ if (shape !== null)
235
+ return `the checkout folder ${folder} ${shape}`;
236
+ if (!path.isAbsolute(folder) || (platform === 'win32' && !/^[A-Za-z]:[\\/]/.test(folder))) {
237
+ return `the checkout folder ${folder} is not an absolute path on this machine`;
238
+ }
239
+ const resolved = path.resolve(folder);
240
+ if (norm(resolved, platform) === norm(path.parse(resolved).root, platform)) {
241
+ return `the checkout folder ${folder} is a whole drive; choose a folder on it, like ${path.join(resolved, 'looply', 'workspaces')}`;
242
+ }
243
+ // The default folder is the one place inside ~/.looply a checkout may live.
244
+ const isDefault = norm(resolved, platform) === norm((0, exports.defaultWorkspacesBase)(deps.home), platform);
245
+ if (!isDefault && within(looplyHome(deps.home), resolved, platform)) {
246
+ return `the checkout folder ${folder} is inside Looply's own state (${looplyHome(deps.home)})`;
247
+ }
248
+ for (const sys of systemFolders(platform, env, deps.home)) {
249
+ if (within(sys, resolved, platform))
250
+ return `the checkout folder ${folder} is inside a system folder (${sys})`;
251
+ }
252
+ if (norm(resolved, platform) === norm(deps.home, platform) || norm(resolved, platform) === norm(path.dirname(deps.home), platform)) {
253
+ return `the checkout folder ${folder} is a home folder itself; choose a folder for checkouts only`;
254
+ }
255
+ for (const served of deps.servedPaths) {
256
+ if (within(served, resolved, platform)) {
257
+ return `the checkout folder ${folder} is inside ${served}, which this machine serves; checkouts there would be mistaken for its files`;
258
+ }
259
+ if (within(resolved, served, platform)) {
260
+ return `the checkout folder ${folder} contains ${served}, which this machine serves; choose a folder for checkouts only`;
261
+ }
262
+ }
263
+ const kind = (deps.driveKind ?? ((p) => nodeDriveKind(p, platform)))(resolved);
264
+ if (kind === 'missing')
265
+ return `the drive of ${folder} does not exist on this machine`;
266
+ if (kind !== 'fixed') {
267
+ return `the checkout folder ${folder} is on a ${kind === 'unknown' ? 'drive whose kind could not be read' : `${kind} drive`}; checkouts need a local fixed drive`;
268
+ }
269
+ try {
270
+ (deps.probeWritable ?? nodeProbeWritable)(resolved);
271
+ }
272
+ catch (e) {
273
+ return `the checkout folder ${folder} could not be created or written (${e.code ?? e.message})`;
274
+ }
275
+ return null;
276
+ }
277
+ /**
278
+ * What applies right now, from the three sources in order. Reads two small
279
+ * files; never asks the server and never checks a folder (the pull did that).
280
+ */
281
+ function effectiveWorkspaces(home = (0, node_os_1.homedir)(), env = process.env) {
282
+ const local = readLocalMachineSettings(home);
283
+ const remote = readRemoteMachineSettings(home);
284
+ let base;
285
+ let source;
286
+ const fromEnv = env['LOOPLY_WORKSPACES_ROOT'];
287
+ if (fromEnv !== undefined && fromEnv !== '') {
288
+ if (!(0, node_path_1.isAbsolute)(fromEnv))
289
+ throw new Error('LOOPLY_WORKSPACES_ROOT must be an absolute path');
290
+ base = fromEnv;
291
+ source = 'local';
292
+ }
293
+ else if (local.workspacesRoot !== undefined) {
294
+ base = local.workspacesRoot;
295
+ source = 'local';
296
+ }
297
+ else if (remote?.inEffect) {
298
+ base = remote.inEffect;
299
+ source = 'remote';
300
+ }
301
+ else {
302
+ base = (0, exports.defaultWorkspacesBase)(home);
303
+ source = 'default';
304
+ }
305
+ // Said only while the remote folder is what WOULD apply: a local override
306
+ // makes the remote folder irrelevant, not refused.
307
+ let refused = null;
308
+ const wanted = remote?.settings.workspacesRoot ?? null;
309
+ if (source !== 'local' && wanted !== null && remote?.verdict?.value === wanted && !remote.verdict.ok) {
310
+ refused = `${remote.verdict.because ?? 'the remote checkout folder was refused'} — new checkouts stay in ${base}`;
311
+ }
312
+ return {
313
+ base,
314
+ source,
315
+ refused,
316
+ minFreeGb: local.minFreeGb ?? remote?.settings.minFreeGb ?? core_1.DEFAULT_MIN_FREE_GB,
317
+ capGb: local.checkoutsCapGb ?? remote?.settings.checkoutsCapGb ?? null,
318
+ };
319
+ }
320
+ /* ─────────────────────────────────────────────── every folder ever used ── */
321
+ /**
322
+ * Remember that checkouts are being made under `base`, so a later change of
323
+ * folder does not hide them from the reaper. Idempotent; never throws.
324
+ */
325
+ function rememberWorkspacesBase(base, home = (0, node_os_1.homedir)()) {
326
+ try {
327
+ const known = knownWorkspacesBases(home, false);
328
+ const key = norm(base, process.platform);
329
+ if (known.some((b) => norm(b, process.platform) === key))
330
+ return;
331
+ writeJson((0, node_path_1.join)(looplyHome(home), exports.WORKSPACES_BASES_FILE), { bases: [...known, (0, node_path_1.resolve)(base)] });
332
+ }
333
+ catch {
334
+ /* forgetting a base costs a manual reap later, never a checkout now */
335
+ }
336
+ }
337
+ /**
338
+ * Every folder checkouts may sit in: the current one first, then every one
339
+ * remembered, then the default. Only folders that exist are returned unless
340
+ * `existingOnly` is false.
341
+ */
342
+ function knownWorkspacesBases(home = (0, node_os_1.homedir)(), includeCurrent = true) {
343
+ const out = [];
344
+ const seen = new Set();
345
+ const add = (b) => {
346
+ const key = norm(b, process.platform);
347
+ if (seen.has(key))
348
+ return;
349
+ seen.add(key);
350
+ out.push(b);
351
+ };
352
+ if (includeCurrent) {
353
+ try {
354
+ add(effectiveWorkspaces(home).base);
355
+ }
356
+ catch {
357
+ /* an unusable current setting still leaves the remembered ones */
358
+ }
359
+ }
360
+ const raw = readJson((0, node_path_1.join)(looplyHome(home), exports.WORKSPACES_BASES_FILE));
361
+ if (raw && Array.isArray(raw.bases)) {
362
+ for (const b of raw.bases)
363
+ if (typeof b === 'string' && (0, node_path_1.isAbsolute)(b))
364
+ add(b);
365
+ }
366
+ if (includeCurrent)
367
+ add((0, exports.defaultWorkspacesBase)(home));
368
+ return out;
369
+ }
370
+ /** Free GB on the drive that holds `dir` (or its nearest existing parent). */
371
+ function freeGbAt(dir) {
372
+ let at = (0, node_path_1.resolve)(dir);
373
+ for (;;) {
374
+ try {
375
+ const s = (0, node_fs_1.statfsSync)(at);
376
+ return (Number(s.bavail) * Number(s.bsize)) / GB;
377
+ }
378
+ catch {
379
+ const up = (0, node_path_1.dirname)(at);
380
+ if (up === at)
381
+ return null;
382
+ at = up;
383
+ }
384
+ }
385
+ }
386
+ /**
387
+ * Bytes under `dir`, walking without following links (a carried
388
+ * `node_modules` link must not count its target, and a junction must not walk
389
+ * the user's repository). Yields to the event loop as it goes, so a daemon's
390
+ * heartbeat is never held by a big tree.
391
+ */
392
+ async function bytesUnder(dir) {
393
+ let total = 0;
394
+ const stack = [dir];
395
+ while (stack.length > 0) {
396
+ const d = stack.pop();
397
+ let entries;
398
+ try {
399
+ entries = await (0, promises_1.readdir)(d, { withFileTypes: true });
400
+ }
401
+ catch {
402
+ continue;
403
+ }
404
+ for (const e of entries) {
405
+ const p = (0, node_path_1.join)(d, e.name);
406
+ if (e.isSymbolicLink())
407
+ continue;
408
+ if (e.isDirectory()) {
409
+ stack.push(p);
410
+ continue;
411
+ }
412
+ try {
413
+ total += (await (0, promises_1.lstat)(p)).size;
414
+ }
415
+ catch {
416
+ /* vanished */
417
+ }
418
+ }
419
+ }
420
+ return total;
421
+ }
422
+ /**
423
+ * Measure every checkout under every known folder: `<base>/<project key>/<checkout>`.
424
+ * Junction/symlink entries are not followed (see `bytesUnder`).
425
+ */
426
+ async function measureCheckouts(bases, now = new Date()) {
427
+ let bytes = 0;
428
+ let checkouts = 0;
429
+ for (const base of bases) {
430
+ let projects;
431
+ try {
432
+ projects = await (0, promises_1.readdir)(base, { withFileTypes: true });
433
+ }
434
+ catch {
435
+ continue;
436
+ }
437
+ for (const p of projects) {
438
+ if (!p.isDirectory())
439
+ continue;
440
+ let names;
441
+ try {
442
+ names = await (0, promises_1.readdir)((0, node_path_1.join)(base, p.name), { withFileTypes: true });
443
+ }
444
+ catch {
445
+ continue;
446
+ }
447
+ for (const n of names) {
448
+ if (!n.isDirectory())
449
+ continue;
450
+ checkouts += 1;
451
+ bytes += await bytesUnder((0, node_path_1.join)(base, p.name, n.name));
452
+ }
453
+ }
454
+ }
455
+ return { usedGb: bytes / GB, checkouts, measuredAt: now.toISOString() };
456
+ }
457
+ function readCheckoutsUsage(home = (0, node_os_1.homedir)()) {
458
+ const raw = readJson((0, node_path_1.join)(looplyHome(home), exports.CHECKOUTS_USAGE_FILE));
459
+ if (!raw || typeof raw['usedGb'] !== 'number' || typeof raw['measuredAt'] !== 'string')
460
+ return null;
461
+ return {
462
+ usedGb: raw['usedGb'],
463
+ checkouts: typeof raw['checkouts'] === 'number' ? raw['checkouts'] : 0,
464
+ measuredAt: raw['measuredAt'],
465
+ };
466
+ }
467
+ /**
468
+ * Measure again when the last measurement is older than `maxAgeMs`. Many
469
+ * daemons share this machine, so one claims the measurement and the others
470
+ * use what is there. Never throws.
471
+ */
472
+ async function refreshCheckoutsUsage(home = (0, node_os_1.homedir)(), opts = {}) {
473
+ const now = opts.now ?? (() => Date.now());
474
+ const maxAge = opts.maxAgeMs ?? exports.USAGE_MAX_AGE_MS;
475
+ const file = (0, node_path_1.join)(looplyHome(home), exports.CHECKOUTS_USAGE_FILE);
476
+ const current = readCheckoutsUsage(home);
477
+ if (current !== null && now() - Date.parse(current.measuredAt) < maxAge)
478
+ return current;
479
+ const raw = readJson(file);
480
+ const since = typeof raw?.measuringSince === 'string' ? Date.parse(raw.measuringSince) : NaN;
481
+ if (Number.isFinite(since) && now() - since < USAGE_CLAIM_MS)
482
+ return current;
483
+ try {
484
+ writeJson(file, { ...(current ?? {}), measuringSince: new Date(now()).toISOString() });
485
+ const measured = await (opts.measure ?? measureCheckouts)(knownWorkspacesBases(home));
486
+ writeJson(file, measured);
487
+ return measured;
488
+ }
489
+ catch {
490
+ return current;
491
+ }
492
+ }
493
+ /**
494
+ * Why no new checkout may be made under `root` right now, or null when one may.
495
+ * Minimum free space first (the drive), then the cap (Looply's share of it).
496
+ * The answer is a sentence a person can act on, because it is what the run and
497
+ * the ticket say.
498
+ */
499
+ function checkoutRoomProblem(root, eff, deps = {}) {
500
+ if (eff.minFreeGb > 0) {
501
+ const free = (deps.freeGb ?? freeGbAt)(root);
502
+ if (free !== null && free < eff.minFreeGb) {
503
+ return (`not enough disk space for a new checkout: ${free.toFixed(1)} GB free where checkouts go (${root}), ` +
504
+ `and at least ${eff.minFreeGb} GB must stay free. Free some space, reap old checkouts ` +
505
+ '(`looply runs --reap`), or move checkouts to another drive in the machine settings.');
506
+ }
507
+ }
508
+ if (eff.capGb !== null) {
509
+ const usage = deps.usage;
510
+ if (usage) {
511
+ const next = usage.checkouts > 0 ? usage.usedGb / usage.checkouts : 0;
512
+ if (usage.usedGb >= eff.capGb || usage.usedGb + next > eff.capGb) {
513
+ return (`checkouts already use ${usage.usedGb.toFixed(1)} GB of the ${eff.capGb} GB cap set for this machine` +
514
+ (next > 0 ? ` (a new one needs about ${next.toFixed(1)} GB)` : '') +
515
+ '. Let running work finish and old checkouts be reaped (`looply runs --reap`), or raise the cap in the machine settings.');
516
+ }
517
+ }
518
+ }
519
+ return null;
520
+ }
521
+ /** What the heartbeat says about checkouts. Never throws: presence is worth more. */
522
+ function workspacesReport(home = (0, node_os_1.homedir)(), deps = {}) {
523
+ let eff;
524
+ try {
525
+ eff = effectiveWorkspaces(home);
526
+ }
527
+ catch (e) {
528
+ // An unusable LOCAL setting: no checkout can be made, and the owner should hear why.
529
+ return {
530
+ root: (0, exports.defaultWorkspacesBase)(home),
531
+ source: 'local',
532
+ freeGb: null,
533
+ usedGb: null,
534
+ refused: null,
535
+ blocked: `no new checkout can be made: ${e.message}`,
536
+ };
537
+ }
538
+ try {
539
+ const free = (deps.freeGb ?? freeGbAt)(eff.base);
540
+ const usage = deps.usage !== undefined ? deps.usage : readCheckoutsUsage(home);
541
+ const blocked = checkoutRoomProblem(eff.base, eff, { freeGb: () => free, usage });
542
+ const round = (n) => typeof n === 'number' && Number.isFinite(n) ? Math.round(n * 10) / 10 : null;
543
+ return {
544
+ root: (0, node_path_1.resolve)(eff.base),
545
+ source: eff.source,
546
+ freeGb: round(free),
547
+ usedGb: round(usage?.usedGb),
548
+ refused: eff.refused,
549
+ blocked,
550
+ };
551
+ }
552
+ catch {
553
+ return undefined;
554
+ }
555
+ }
556
+ /**
557
+ * One pull: ask the server for this machine's settings, check a new folder on
558
+ * this machine, and write the cache. A failure keeps what the cache holds.
559
+ */
560
+ function makeMachineSettingsPull(deps) {
561
+ const home = deps.home ?? (0, node_os_1.homedir)();
562
+ const now = deps.now ?? (() => Date.now());
563
+ const check = deps.checkFolder ?? checkoutFolderProblem;
564
+ let failureReported = false;
565
+ const reportOnce = (why) => {
566
+ if (failureReported)
567
+ return;
568
+ failureReported = true;
569
+ deps.log(`disk : could not refresh this machine's checkout settings — ${why}`);
570
+ };
571
+ return async () => {
572
+ try {
573
+ return await pull();
574
+ }
575
+ finally {
576
+ try {
577
+ await (deps.refreshUsage ?? (() => refreshCheckoutsUsage(home)))();
578
+ }
579
+ catch {
580
+ /* a measurement is never a stopped daemon */
581
+ }
582
+ }
583
+ };
584
+ async function pull() {
585
+ let body;
586
+ try {
587
+ body = await deps.fetch();
588
+ }
589
+ catch (e) {
590
+ if (!(e instanceof server_client_1.ServerError && (e.isOffline || e.isAuth || e.status === 404)))
591
+ reportOnce(e.message);
592
+ return 'failed';
593
+ }
594
+ const b = body;
595
+ if (typeof b !== 'object' || b === null || !('settings' in b)) {
596
+ reportOnce('the server answered with something that is not a settings envelope');
597
+ return 'failed';
598
+ }
599
+ const v = (0, core_1.validateMachineSettings)(b.settings);
600
+ if (!v.ok) {
601
+ reportOnce(`the server sent settings this build refuses (${v.key}: ${v.because})`);
602
+ return 'failed';
603
+ }
604
+ failureReported = false;
605
+ const settings = v.settings;
606
+ const updatedAt = typeof b.updatedAt === 'string' ? b.updatedAt : null;
607
+ const before = readRemoteMachineSettings(home);
608
+ let inEffect = before?.inEffect ?? null;
609
+ let verdict = before?.verdict ?? null;
610
+ const wanted = settings.workspacesRoot;
611
+ if (wanted === null) {
612
+ inEffect = null;
613
+ verdict = null;
614
+ }
615
+ else {
616
+ const judged = verdict !== null && verdict.value === wanted;
617
+ const stale = judged && !verdict.ok && now() - (Date.parse(verdict.checkedAt) || 0) >= REFUSED_RECHECK_MS;
618
+ if (!judged || stale) {
619
+ let because;
620
+ try {
621
+ because = check(wanted, { home, servedPaths: deps.servedPaths() });
622
+ }
623
+ catch (e) {
624
+ because = `the checkout folder ${wanted} could not be checked (${e.message})`;
625
+ }
626
+ verdict = { value: wanted, ok: because === null, because, checkedAt: new Date(now()).toISOString() };
627
+ if (because === null) {
628
+ inEffect = wanted;
629
+ deps.log(`disk : new checkouts go to ${wanted} (machine setting); checkouts already made stay where they are`);
630
+ }
631
+ else {
632
+ // 🔴 THE PREVIOUS FOLDER STAYS. A refused folder must not send checkouts back to the home drive.
633
+ deps.log(`disk : refused the checkout folder from the machine settings — ${because}. Keeping ${inEffect ?? (0, exports.defaultWorkspacesBase)(home)}`);
634
+ }
635
+ }
636
+ }
637
+ const next = {
638
+ settings,
639
+ updatedAt,
640
+ fetchedAt: new Date(now()).toISOString(),
641
+ inEffect,
642
+ verdict,
643
+ };
644
+ const same = before !== null &&
645
+ JSON.stringify({ ...before, fetchedAt: '' }) === JSON.stringify({ ...next, fetchedAt: '' });
646
+ if (same)
647
+ return 'unchanged';
648
+ try {
649
+ writeRemoteMachineSettings(home, next);
650
+ }
651
+ catch (e) {
652
+ reportOnce(`the cache could not be written (${e.message})`);
653
+ return 'failed';
654
+ }
655
+ return 'updated';
656
+ }
657
+ }
658
+ //# sourceMappingURL=machine-settings.js.map