@rightkit/release 0.2.158 → 0.2.159

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/disk-command.mjs CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { spawnSync } from "./background-process.mjs";
7
7
  import {
8
- DISK_EXIT_WARNING, applySweep, checkDisk, formatBytes, listOpenPaths, logEvent, planSweep, platformKey, readDiskPolicy,
8
+ DISK_EXIT_WARNING, applySweep, checkDisk, formatBytes, listOpenPaths, logEvent, planSweep, platformKey, purgeHoldings, readDiskPolicy,
9
9
  } from "./disk-policy.mjs";
10
10
 
11
11
  export const LAUNCHD_LABEL = "dev.rightkit.disk-hygiene";
@@ -54,8 +54,6 @@ function parse(rest) {
54
54
  return opts;
55
55
  }
56
56
 
57
- const CHECKOUT_FILE = fileURLToPath(import.meta.url);
58
-
59
57
  // A path inside any git checkout (a `.git` entry on an ancestor) is development code, never an installed package.
60
58
  export function insideGitCheckout(file, exists = existsSync) {
61
59
  let dir = path.dirname(file);
@@ -68,18 +66,17 @@ export function insideGitCheckout(file, exists = existsSync) {
68
66
  }
69
67
 
70
68
  /** Installed @rightkit/release CLI for the Windows task: explicit --cli / RIGHT_RELEASE_INSTALLED_CLI, else the global
71
- * node_modules (`npm root -g`, `pnpm root -g`). A checkout, including the one running install, is refused. */
72
- export function resolveInstalledCli({ explicit, env = process.env, globalRoots = defaultGlobalRoots, exists = existsSync, checkoutFile = CHECKOUT_FILE } = {}) {
69
+ * node_modules (`npm root -g`, `pnpm root -g`). Only a candidate inside a git checkout is refused; the installed package may itself run install. */
70
+ export function resolveInstalledCli({ explicit, env = process.env, globalRoots = defaultGlobalRoots, exists = existsSync } = {}) {
73
71
  const rel = path.join("@rightkit", "release", "cli", "right-release.mjs");
74
72
  const candidates = explicit || env.RIGHT_RELEASE_INSTALLED_CLI
75
73
  ? [explicit || env.RIGHT_RELEASE_INSTALLED_CLI]
76
74
  : globalRoots({ env }).map((root) => path.join(root, rel));
77
- const checkoutCli = path.join(path.dirname(checkoutFile), "cli", "right-release.mjs");
78
75
  for (const candidate of candidates) {
79
76
  if (!exists(candidate)) continue;
80
77
  let real = candidate;
81
78
  try { real = realpathSync(candidate); } catch { /* keep lexical */ }
82
- if (real === checkoutCli || insideGitCheckout(real, exists)) continue;
79
+ if (insideGitCheckout(real, exists)) continue;
83
80
  return candidate;
84
81
  }
85
82
  throw new Error("disk schedule install needs the installed @rightkit/release (not a checkout): install it globally or pass --cli <path to the installed cli/right-release.mjs>");
@@ -183,6 +180,11 @@ export async function runDiskCommand(args = process.argv.slice(2), { env = proce
183
180
  out({ ...p, applied }, renderSweep(p, applied));
184
181
  return { exitCode: applied.results.some((r) => !r.ok) ? 1 : 0, plan: p, applied };
185
182
  }
183
+ if (command === "purge") {
184
+ const purged = purgeHoldings({ policy, platform, env, days: opts.days, dryRun: opts.dryRun, ...deps.purge });
185
+ out(purged, `${opts.dryRun ? "Dry run: would purge" : "Purged"} ${purged.results.length} holding dir(s)${purged.results.map((r) => `\n ${formatBytes(r.bytes).padStart(9)} ${r.path}${r.ok ? "" : ` FAILED: ${r.error}`}`).join("")}`);
186
+ return { exitCode: purged.results.some((r) => !r.ok) ? 1 : 0, purged };
187
+ }
186
188
  if (command === "run") {
187
189
  const before = check();
188
190
  stdout(renderCheck(before));
@@ -190,13 +192,15 @@ export async function runDiskCommand(args = process.argv.slice(2), { env = proce
190
192
  stdout(renderSweep(p));
191
193
  const applied = applySweep({ plan: p, policy, platform, ...deps.apply });
192
194
  stdout(`moved ${formatBytes(applied.movedBytes)} (${applied.results.filter((r) => r.ok).length}/${applied.results.length})`);
195
+ const purged = purgeHoldings({ policy, platform, env, ...deps.purge });
196
+ stdout(`purged ${purged.results.filter((r) => r.ok).length} holding dir(s), ${formatBytes(purged.purgedBytes)}`);
193
197
  const after = check();
194
198
  const warn = after.warn;
195
199
  logEvent(logFile, { event: warn ? "disk.floor.warn" : "disk.run.ok", warnings: after.warnings, freeBytes: after.floor.freeBytes, movedBytes: applied.movedBytes });
196
200
  if (warn) stdout(`WARNING: ${after.warnings.join("; ")}`);
197
- return { exitCode: warn ? DISK_EXIT_WARNING : 0, before, after, applied };
201
+ return { exitCode: warn ? DISK_EXIT_WARNING : 0, before, after, applied, purged };
198
202
  }
199
- throw new Error("disk usage: check|sweep [--dry-run|--delete] [--days N]|run|schedule install|uninstall|status [--json]");
203
+ throw new Error("disk usage: check|sweep [--dry-run|--delete] [--days N]|purge [--dry-run] [--days N]|run|schedule install|uninstall|status [--json]");
200
204
  }
201
205
 
202
206
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
package/disk-policy.json CHANGED
@@ -5,6 +5,11 @@
5
5
  "mac": {"path": "/System/Volumes/Data", "label": "Mac internal", "warnFreeBytes": 42949672960},
6
6
  "win": {"path": "D:\\", "label": "Dell D:", "warnFreeBytes": 429496729600, "warnFreePercent": 10}
7
7
  },
8
+ "holdingDays": 3,
9
+ "holding": {
10
+ "mac": [{"prefix": "/", "path": "~/.rightkit-swept"}],
11
+ "win": [{"prefix": "C:\\", "path": "${LOCALAPPDATA}\\Orthic Labs\\.rightkit-swept"}]
12
+ },
8
13
  "heavyVolume": {
9
14
  "mac": "/Volumes/D/claude/tmp/swept",
10
15
  "win": "D:\\claude\\tmp\\swept"
@@ -44,5 +49,5 @@
44
49
  {"id": "leftover-legion-final", "platform": "mac", "path": "/Volumes/D/claude/legion-final-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"},
45
50
  {"id": "leftover-pulse-bak", "platform": "win", "path": "${LOCALAPPDATA}\\Programs\\Pulse.bak-*", "capBytes": 1073741824, "action": "sweep", "unit": "self"}
46
51
  ],
47
- "protectedSegments": ["Application Support", "Keychains", "Keychain", "HeardRightNext", "Credentials", ".ssh", ".rightkit-managed"]
52
+ "protectedSegments": ["Application Support", "Keychains", "Keychain", "HeardRightNext", "Credentials", ".ssh", ".rightkit-managed", ".rightkit-swept"]
48
53
  }
package/disk-policy.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, cpSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, appendFileSync } from "node:fs";
1
+ import { closeSync, cpSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, appendFileSync, writeFileSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -224,9 +224,68 @@ export function planSweep({ policy, platform = process.platform, env = process.e
224
224
  return { schema: 1, platform: key, staleDays, candidates, skipped, totalBytes: candidates.reduce((s, c) => s + c.bytes, 0) };
225
225
  }
226
226
 
227
- export function sweptDestination({ policy, platform = process.platform, date = new Date() }) {
227
+ export const HOLDING_DIR = ".rightkit-swept";
228
+ export const HOLDING_MARKER = ".rightkit-release-holding";
229
+ const DATE_DIR = /^\d{4}-\d{2}-\d{2}$/;
230
+
231
+ // Device id of a path, or of its nearest existing ancestor (null when none can be read).
232
+ export function deviceOf(target) {
233
+ let cur = target;
234
+ for (;;) {
235
+ try { return statSync(cur).dev; } catch {
236
+ const up = path.dirname(cur);
237
+ if (up === cur) return null;
238
+ cur = up;
239
+ }
240
+ }
241
+ }
242
+
243
+ // Highest ancestor of `target` still on the same device: the mount point (or drive root) holding it.
244
+ export function volumeRoot(target) {
245
+ const dev = deviceOf(target);
246
+ let cur = target;
247
+ for (;;) {
248
+ const up = path.dirname(cur);
249
+ if (up === cur || deviceOf(up) !== dev) return cur;
250
+ cur = up;
251
+ }
252
+ }
253
+
254
+ const isUnder = (candidate, prefix, win) => {
255
+ const norm = (v) => (win ? v.toLowerCase() : v);
256
+ const c = norm(candidate); const p = norm(prefix);
257
+ const withSep = p.endsWith(path.sep) || p.endsWith("/") || p.endsWith("\\") ? p : p + path.sep;
258
+ return c === p || c.startsWith(withSep);
259
+ };
260
+
261
+ // Candidate holding bases for an item, best first: policy per-volume rules (longest prefix), then the item's own volume root.
262
+ export function holdingBases(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir() } = {}) {
228
263
  const key = platformKey(platform);
229
- return path.join(policy.heavyVolume[key], date.toISOString().slice(0, 10));
264
+ const out = [];
265
+ const rules = (policy.holding?.[key] ?? []).flatMap((r) => {
266
+ try { return [{ prefix: expandPolicyPath(r.prefix, { env, home }), path: expandPolicyPath(r.path, { env, home }) }]; } catch { return []; }
267
+ }).filter((r) => isUnder(candidate, r.prefix, key === "win")).sort((a, b) => b.prefix.length - a.prefix.length);
268
+ for (const r of rules) out.push(r.path);
269
+ out.push(path.join(volumeRoot(path.dirname(candidate)), HOLDING_DIR));
270
+ return out;
271
+ }
272
+
273
+ // Create (and mark) today's holding dir on the item's own volume; null when no holding dir on that volume is possible.
274
+ export function pickHolding(candidate, { policy, platform = process.platform, env = process.env, home = os.homedir(), date = new Date(), bases } = {}) {
275
+ const dev = deviceOf(path.dirname(candidate));
276
+ for (const base of bases ?? holdingBases(candidate, { policy, platform, env, home })) {
277
+ try {
278
+ if (deviceOf(base) !== dev) continue; // checked before creating anything: a base on another volume would free nothing
279
+ mkdirSync(base, { recursive: true });
280
+ if (deviceOf(base) !== dev || lstatSync(base).isSymbolicLink()) continue;
281
+ const marker = path.join(base, HOLDING_MARKER);
282
+ if (!existsSync(marker)) writeFileSync(marker, "created by right-release disk; date-named children are purged after the retention window\n");
283
+ const dir = path.join(base, date.toISOString().slice(0, 10));
284
+ mkdirSync(dir, { recursive: true });
285
+ return dir;
286
+ } catch { /* try the next base */ }
287
+ }
288
+ return null;
230
289
  }
231
290
 
232
291
  export function moveEntry(source, destination) {
@@ -248,19 +307,27 @@ export function logEvent(logFile, event) {
248
307
  } catch { /* logging must never fail the sweep */ }
249
308
  }
250
309
 
251
- export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), move = moveEntry, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
252
- const destRoot = sweptDestination({ policy, platform, date });
310
+ export function applySweep({ plan, policy, platform = process.platform, deleteMode = false, date = new Date(), env = process.env, home = os.homedir(), move = moveEntry, pick = pickHolding, remove = (p) => rmSync(p, { recursive: true, force: true }) }) {
253
311
  const logFile = path.join(policy.heavyVolume[platformKey(platform)], "disk-hygiene.jsonl");
254
312
  const results = [];
255
313
  for (const c of plan.candidates) {
256
- const rel = path.join(c.writer, path.basename(c.path));
257
- let destination = path.join(destRoot, rel);
258
- if (existsSync(destination)) destination = `${destination}.${date.getTime()}`;
259
314
  try {
260
315
  // Re-verify at apply time: still the planned unit, not a symlink, still inside its root.
261
316
  if (!c.root || lstatSync(c.path).isSymbolicLink() || !isInsideRoot(c.path, c.root)) throw new Error("refused: path is not a sweepable unit inside its target root");
262
- if (deleteMode) { remove(c.path); results.push({ ...c, action: "delete", ok: true }); logEvent(logFile, { event: "disk.sweep.delete", ...c }); }
263
- else { const how = move(c.path, destination); results.push({ ...c, action: "move", destination, how, ok: true }); logEvent(logFile, { event: "disk.sweep.move", ...c, destination, how }); }
317
+ if (deleteMode) { remove(c.path); results.push({ ...c, action: "delete", ok: true }); logEvent(logFile, { event: "disk.sweep.delete", ...c }); continue; }
318
+ // An item stays on its own volume: moving it elsewhere frees nothing on the volume that is short of space.
319
+ const holding = pick(c.path, { policy, platform, env, home, date });
320
+ if (!holding) {
321
+ remove(c.path);
322
+ results.push({ ...c, action: "delete", how: "no-holding-on-volume", ok: true });
323
+ logEvent(logFile, { event: "disk.sweep.delete", reason: "no holding dir possible on the item's volume", ...c });
324
+ continue;
325
+ }
326
+ let destination = path.join(holding, c.writer, path.basename(c.path));
327
+ if (existsSync(destination)) destination = `${destination}.${date.getTime()}`;
328
+ const how = move(c.path, destination);
329
+ results.push({ ...c, action: "move", destination, how, ok: true });
330
+ logEvent(logFile, { event: "disk.sweep.move", ...c, destination, how });
264
331
  } catch (error) {
265
332
  results.push({ ...c, action: deleteMode ? "delete" : "move", ok: false, error: error.message });
266
333
  logEvent(logFile, { event: "disk.sweep.error", ...c, error: error.message });
@@ -268,3 +335,48 @@ export function applySweep({ plan, policy, platform = process.platform, deleteMo
268
335
  }
269
336
  return { logFile, results, movedBytes: results.filter((r) => r.ok).reduce((s, r) => s + r.bytes, 0) };
270
337
  }
338
+
339
+ // Holding bases that may exist: policy rule paths, each drive root (Windows) or /Volumes/* and the legacy heavy dir (Mac).
340
+ export function knownHoldingBases({ policy, platform = process.platform, env = process.env, home = os.homedir() } = {}) {
341
+ const key = platformKey(platform);
342
+ const bases = new Set();
343
+ for (const r of policy.holding?.[key] ?? []) { try { bases.add(expandPolicyPath(r.path, { env, home })); } catch { /* unset variable */ } }
344
+ if (key === "win") for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { const root = `${letter}:\\`; if (existsSync(root)) bases.add(path.join(root, HOLDING_DIR)); }
345
+ else { bases.add(path.join(path.sep, HOLDING_DIR)); try { for (const v of readdirSync("/Volumes")) bases.add(path.join("/Volumes", v, HOLDING_DIR)); } catch { /* no /Volumes */ } }
346
+ return [...bases];
347
+ }
348
+
349
+ /** Remove date-named holding dirs older than `days`. Only dirs this tool created: the base must carry the holding marker
350
+ * (or, for the legacy heavy dir, the tool's own log), the child must be a real directory named YYYY-MM-DD, never a symlink. */
351
+ export function purgeHoldings({ policy, platform = process.platform, env = process.env, home = os.homedir(), now = Date.now(), days = policy.holdingDays ?? 3, dryRun = false, bases, remove = (p) => rmSync(p, { recursive: true, force: true }), size = walkSize } = {}) {
352
+ const key = platformKey(platform);
353
+ const legacy = policy.heavyVolume?.[key];
354
+ const list = bases ?? [...knownHoldingBases({ policy, platform, env, home }), ...(legacy ? [legacy] : [])];
355
+ const logFile = legacy ? path.join(legacy, "disk-hygiene.jsonl") : null;
356
+ const cutoff = now - days * 86400_000;
357
+ const results = [];
358
+ for (const base of new Set(list)) {
359
+ let st;
360
+ try { st = lstatSync(base); } catch { continue; }
361
+ if (!st.isDirectory() || st.isSymbolicLink()) continue;
362
+ const owned = existsSync(path.join(base, HOLDING_MARKER)) || (base === legacy && existsSync(path.join(base, "disk-hygiene.jsonl")));
363
+ if (!owned) continue;
364
+ let names = [];
365
+ try { names = readdirSync(base); } catch { continue; }
366
+ for (const name of names) {
367
+ if (!DATE_DIR.test(name)) continue;
368
+ const dir = path.join(base, name);
369
+ let dst;
370
+ try { dst = lstatSync(dir); } catch { continue; }
371
+ if (!dst.isDirectory() || dst.isSymbolicLink()) continue;
372
+ const stamp = Date.parse(`${name}T00:00:00Z`);
373
+ if (!Number.isFinite(stamp) || stamp + 86400_000 > cutoff) continue; // the whole day must be older than the window
374
+ const bytes = size(dir);
375
+ try {
376
+ if (!dryRun) { remove(dir); if (logFile) logEvent(logFile, { event: "disk.purge", path: dir, bytes }); }
377
+ results.push({ path: dir, bytes, ok: true, dryRun });
378
+ } catch (error) { results.push({ path: dir, bytes, ok: false, error: error.message }); }
379
+ }
380
+ }
381
+ return { results, purgedBytes: results.filter((r) => r.ok && !r.dryRun).reduce((a, r) => a + r.bytes, 0) };
382
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.158",
3
+ "version": "0.2.159",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
  "stagedNpm": {
16
16
  "@rightkit/app-update": "0.2.5",
17
17
  "@rightkit/ax": "0.3.2",
18
- "@rightkit/ci": "0.2.43",
18
+ "@rightkit/ci": "0.2.44",
19
19
  "@rightkit/doc-render": "0.1.6",
20
20
  "@rightkit/hooks": "0.1.2",
21
21
  "@rightkit/i18n": "0.1.0",
@@ -31,7 +31,7 @@
31
31
  "@rightkit/qa": "0.4.8",
32
32
  "@rightkit/qa-chrome": "0.1.6",
33
33
  "@rightkit/react-shell": "0.6.4",
34
- "@rightkit/release": "0.2.158",
34
+ "@rightkit/release": "0.2.159",
35
35
  "@rightkit/settings-ui": "0.4.4",
36
36
  "@rightkit/tauri": "0.2.1",
37
37
  "@rightkit/tauri-shell": "0.1.1",
@@ -199,7 +199,8 @@
199
199
  "0.2.150",
200
200
  "0.2.151",
201
201
  "0.2.155",
202
- "0.2.156"
202
+ "0.2.156",
203
+ "0.2.158"
203
204
  ],
204
205
  "@rightkit/qa": [
205
206
  "0.1.0",
@@ -326,6 +327,9 @@
326
327
  ],
327
328
  "@rightkit/shell": [
328
329
  "0.1.0"
330
+ ],
331
+ "@rightkit/ci": [
332
+ "0.2.43"
329
333
  ]
330
334
  },
331
335
  "cargo": {
@@ -488,7 +492,7 @@
488
492
  "@rightkit/app-shell": "0.6.2",
489
493
  "@rightkit/app-update": "0.2.5",
490
494
  "@rightkit/ax": "0.3.2",
491
- "@rightkit/ci": "0.2.43",
495
+ "@rightkit/ci": "0.2.44",
492
496
  "@rightkit/doc-render": "0.1.6",
493
497
  "@rightkit/git": "0.2.42",
494
498
  "@rightkit/hooks": "0.1.2",
@@ -505,7 +509,7 @@
505
509
  "@rightkit/qa": "0.4.8",
506
510
  "@rightkit/qa-chrome": "0.1.6",
507
511
  "@rightkit/react-shell": "0.6.4",
508
- "@rightkit/release": "0.2.156",
512
+ "@rightkit/release": "0.2.158",
509
513
  "@rightkit/settings-ui": "0.4.4",
510
514
  "@rightkit/shell": "0.1.1",
511
515
  "@rightkit/tauri": "0.2.1",