artshelf 0.10.1 → 0.11.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +3 -0
  3. package/SPEC.md +25 -4
  4. package/dist/src/adapters/process.js +7 -0
  5. package/dist/src/adapters/update.js +143 -0
  6. package/dist/src/cli.js +44 -1847
  7. package/dist/src/commands/cleanup.js +52 -0
  8. package/dist/src/commands/doctor.js +79 -0
  9. package/dist/src/commands/due.js +32 -0
  10. package/dist/src/commands/find.js +44 -0
  11. package/dist/src/commands/get.js +31 -0
  12. package/dist/src/commands/index.js +69 -0
  13. package/dist/src/commands/ledgers.js +111 -0
  14. package/dist/src/commands/list.js +36 -0
  15. package/dist/src/commands/put.js +36 -0
  16. package/dist/src/commands/resolve.js +17 -0
  17. package/dist/src/commands/review.js +38 -0
  18. package/dist/src/commands/shared.js +160 -0
  19. package/dist/src/commands/status.js +101 -0
  20. package/dist/src/commands/trash.js +78 -0
  21. package/dist/src/commands/update.js +75 -0
  22. package/dist/src/commands/validate.js +35 -0
  23. package/dist/src/config/env.js +24 -0
  24. package/dist/src/config/package.js +17 -0
  25. package/dist/src/config/paths.js +5 -0
  26. package/dist/src/ledger.js +224 -182
  27. package/dist/src/locks.js +73 -0
  28. package/dist/src/registry.js +3 -41
  29. package/dist/src/renderers/attention.js +3 -0
  30. package/dist/src/renderers/doctor.js +64 -0
  31. package/dist/src/renderers/json.js +10 -0
  32. package/dist/src/renderers/review.js +159 -0
  33. package/dist/src/renderers/status.js +112 -0
  34. package/dist/src/shared/cli-types.js +1 -0
  35. package/dist/src/shared/errors.js +4 -0
  36. package/dist/src/shared/flags.js +41 -0
  37. package/dist/src/shared/help-text.js +355 -0
  38. package/docs/agent-usage.html +1 -1
  39. package/docs/agent-usage.md +2 -2
  40. package/docs/reference.html +3 -3
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -79,6 +79,48 @@
79
79
  cache, `ARTSHELF_NO_UPDATE_CHECK_TTL_MS` overrides the no-update/failed TTL
80
80
  (falling back to `ARTSHELF_UPDATE_CHECK_TTL_MS` for compatibility), and a
81
81
  non-numeric TTL value falls back to the default instead of disabling expiry.
82
+ - Made concurrent ledger and registry writes safe: ledger mutations now take the
83
+ same cross-process advisory lock as the registry (extracted into a shared
84
+ `withPathLock` helper in `src/locks.ts`), and ledger appends and rewrites commit
85
+ through a unique temp file and an atomic rename, so overlapping `put`,
86
+ `resolve`, and cleanup runs no longer drop records or leave a partially written
87
+ ledger.
88
+ - Hardened `cleanup --execute` to reject unsafe plan ids and bind the loaded plan
89
+ to the request before any filesystem mutation: the plan's `planId` must match
90
+ the requested id, its `ledgerPath` must match the executing ledger, and its
91
+ entries must be well-formed, so mismatched or malformed plans are refused before
92
+ moving files or writing a receipt — the plan-id-bound posture trash purge
93
+ already enforces.
94
+
95
+ ## [0.11.0](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.10.2...artshelf-v0.11.0) (2026-06-14)
96
+
97
+
98
+ ### Features
99
+
100
+ * **ledger:** add cross-process advisory file lock and unique temp paths for atomic writes (NGX-428) ([0f553e4](https://github.com/calvinnwq/artshelf/commit/0f553e485737cf96390d451f4ae92f52e1abbf2a))
101
+
102
+
103
+ ### Bug Fixes
104
+
105
+ * **cleanup:** reject unsafe plan-ids and mismatched plans before filesystem mutation (NGX-426) ([79debb7](https://github.com/calvinnwq/artshelf/commit/79debb7c3610984a969adea7f93b27ca08150647))
106
+ * **ledger:** make ledger writes atomic and concurrency-safe and reject unsafe cleanup plans ([ac98c4e](https://github.com/calvinnwq/artshelf/commit/ac98c4eaf917b695e166f2ca7c40b6759d6e5f53))
107
+
108
+ ## [0.10.2](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.10.1...artshelf-v0.10.2) (2026-06-13)
109
+
110
+
111
+ ### Code Refactoring
112
+
113
+ * **cli:** extract command dispatch and shared modules from the monolithic
114
+ entrypoint ([c198c19](https://github.com/calvinnwq/artshelf/commit/c198c194693e756dd02b2525e2f6abbee5741d59))
115
+ * **cli:** separate status, doctor, review, and JSON renderers from command
116
+ orchestration ([4ec76b0](https://github.com/calvinnwq/artshelf/commit/4ec76b0b0e4f45562d0e98a1237602bc5d41ca67))
117
+ * **cli:** extract update environment, package, path, and process adapter seams
118
+ ([4ec76b0](https://github.com/calvinnwq/artshelf/commit/4ec76b0b0e4f45562d0e98a1237602bc5d41ca67))
119
+ * **cli:** restore real per-command modules, add the validate command module,
120
+ and strengthen architecture guardrails
121
+ ([a617ba3](https://github.com/calvinnwq/artshelf/commit/a617ba36e7de7d8a5725d1ba47eb3419ae3c6329))
122
+ * **cli:** move help rendering out of the entrypoint and into shared help text
123
+ ([8e2698b](https://github.com/calvinnwq/artshelf/commit/8e2698bce1b47073d434113cbe1e8cfb32ec34e2))
82
124
 
83
125
  ## [0.10.1](https://github.com/calvinnwq/artshelf/compare/artshelf-v0.10.0...artshelf-v0.10.1) (2026-06-12)
84
126
 
package/README.md CHANGED
@@ -113,6 +113,9 @@ destructive deletion.
113
113
  - **No fresh-plan-then-execute shortcut** — review the plan, then run that plan.
114
114
  - **Trash before delete** — `cleanup=delete` stays refused; physical deletion
115
115
  needs its own reviewed trash purge. No silent deletion, ever.
116
+ - **Durable, concurrency-safe writes** — ledger and registry mutations take a
117
+ cross-process lock and commit atomically, so overlapping commands never lose
118
+ records or leave a half-written ledger.
116
119
  - **`--json` on every command**, so agents can act on structured output.
117
120
  - **`--agent` on `review`/`status`/`doctor`**, a compact, token-efficient
118
121
  decision packet for agents, while the default render stays human-scannable.
package/SPEC.md CHANGED
@@ -261,7 +261,7 @@ the next action always points at an explicit follow-up command.
261
261
 
262
262
  `review`, `status`, and `doctor` share three render modes. The default human
263
263
  render leads each ledger and summary line with a `✓`/`⚠` attention glyph; `--json`
264
- stays the full, backward-compatible audit report; and `--agent` emits a compact,
264
+ stays the full, backward-compatible public audit report; and `--agent` emits a compact,
265
265
  deterministic single-line JSON decision packet for agents, taking precedence over
266
266
  `--json` when both are passed. For `review`, the packet sorts records into
267
267
  ready-for-approval, needs-review-first, and blocked groups. Because review is
@@ -423,8 +423,15 @@ artshelf cleanup --execute --plan-id <id> [--ledger <path>] --json
423
423
 
424
424
  Rules:
425
425
 
426
- - Requires `--plan-id`.
426
+ - Requires `--plan-id`, and refuses an unsafe plan id (anything outside
427
+ `[A-Za-z0-9_-]`, such as a value containing path separators or `..`) before
428
+ touching the filesystem.
427
429
  - Refuses to generate a fresh live cleanup set during execute.
430
+ - Binds the loaded plan to the request before any mutation: the plan file's
431
+ `planId` must match the requested id, its `ledgerPath` must match the executing
432
+ ledger, and its entries must be well-formed. A mismatched or malformed plan is
433
+ refused without moving files or writing a receipt, mirroring the live-record
434
+ re-checks `trash purge --execute` performs.
428
435
  - Writes a cleanup receipt and appends or refreshes an Artshelf-owned ledger record
429
436
  for that receipt with `owner=artshelf`, `kind=run-artifact`, `ttl=30d`,
430
437
  `cleanup=review`, and labels including `artshelf`, `cleanup-receipt`, and the
@@ -532,6 +539,16 @@ Default behavior:
532
539
  - Otherwise write user-global.
533
540
  - Allow `--ledger <path>` for explicit tests and unusual workflows.
534
541
 
542
+ Write durability:
543
+
544
+ - Every mutation of a ledger or the registry runs under a cross-process advisory
545
+ lock keyed on the target file, so overlapping `artshelf` processes serialize
546
+ their writes instead of racing. The lock is re-entrant within a process and
547
+ reclaims a stale lock left by a crashed holder.
548
+ - Ledger writes — both single-record appends and full rewrites — land through a
549
+ unique temp file and an atomic rename, so an interrupted write cannot truncate
550
+ the ledger or lose already-recorded entries.
551
+
535
552
  V1 also supports a user-level registry of known ledgers:
536
553
 
537
554
  - registry: `~/.artshelf/ledgers.json`
@@ -784,6 +801,8 @@ human review.
784
801
  - CLI can find existing records by path/owner/label/status and get records by id.
785
802
  - CLI can mark records manually resolved with a required reason.
786
803
  - CLI validates ledger shape.
804
+ - Concurrent ledger and registry writes are serialized with a cross-process lock
805
+ and committed atomically, so overlapping commands do not lose records.
787
806
  - CLI reports machine and registry health through `artshelf doctor`, exiting
788
807
  non-zero when the registry or a registered ledger is broken.
789
808
  - CLI reports a read-only daily dashboard through `artshelf status`, with
@@ -795,7 +814,8 @@ human review.
795
814
  entries; no-op dry-runs do not write plan files.
796
815
  - Cleanup dry-run and execute register the plan/receipt artifacts that Artshelf
797
816
  creates.
798
- - Cleanup execute refuses to run without a plan id.
817
+ - Cleanup execute refuses to run without a plan id, and refuses an unsafe,
818
+ mismatched, or malformed plan before moving files or writing a receipt.
799
819
  - Cleanup execute writes a receipt.
800
820
  - CLI can list trashed records (single ledger or `--all`) and purge them through
801
821
  an approval-first, ledger-scoped dry-run/execute boundary that writes a purge
@@ -807,7 +827,8 @@ human review.
807
827
  JSON decision packet for agents that takes precedence over `--json`.
808
828
  - Tests cover record/list/find/get/status-filter/due/validate/resolve/registry,
809
829
  `artshelf doctor`, the `artshelf status` dashboard, `--all` review, stale-registry,
810
- dry-run, global-dry-run, execute-plan, and trash list/purge behavior.
830
+ dry-run, global-dry-run, execute-plan, cleanup plan-id validation, concurrent
831
+ ledger writes, and trash list/purge behavior.
811
832
 
812
833
  ## Deferred
813
834
 
@@ -0,0 +1,7 @@
1
+ import { spawnSync } from "node:child_process";
2
+ export function installGlobalNpmPackage(packageSpec, mode) {
3
+ if (mode === "pipe") {
4
+ return spawnSync("npm", ["install", "-g", packageSpec], { encoding: "utf8" });
5
+ }
6
+ return spawnSync("npm", ["install", "-g", packageSpec], { encoding: "utf8", stdio: "inherit" });
7
+ }
@@ -0,0 +1,143 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { latestVersionOverride, noUpdateCheckTtlMs, updateCheckTtlMs } from "../config/env.js";
4
+ import { NO_UPDATE_CHECK_TTL_MS, UPDATE_CHECK_TTL_MS, VERSION, npmRegistryUrl } from "../config/package.js";
5
+ import { updateCachePath } from "../config/paths.js";
6
+ export async function getUpdateInfo(options) {
7
+ return createDefaultUpdateAdapter().getUpdateInfo(options);
8
+ }
9
+ export function createUpdateAdapter(options) {
10
+ async function getUpdateInfo(optionsForCheck) {
11
+ const latest = await getLatestVersion(optionsForCheck);
12
+ if (!latest)
13
+ return null;
14
+ return {
15
+ current: options.currentVersion,
16
+ latest,
17
+ updateAvailable: compareVersions(latest, options.currentVersion) > 0
18
+ };
19
+ }
20
+ async function getLatestVersion(optionsForCheck) {
21
+ const override = latestVersionOverride(options.env);
22
+ if (override)
23
+ return normalizeVersion(override);
24
+ if (!optionsForCheck.force) {
25
+ const cached = readUpdateCache();
26
+ if (cached)
27
+ return cached.latest;
28
+ }
29
+ const latest = await options.fetchLatestVersion(options.registryUrl);
30
+ writeUpdateCache(latest);
31
+ return latest;
32
+ }
33
+ function readUpdateCache() {
34
+ const cachePath = options.cachePath();
35
+ if (!options.fileExists(cachePath))
36
+ return null;
37
+ try {
38
+ const cache = JSON.parse(options.readTextFile(cachePath));
39
+ if (!("latest" in cache))
40
+ cache.latest = null;
41
+ if (cache.latest !== null && typeof cache.latest !== "string")
42
+ return null;
43
+ if (typeof cache.checkedAt !== "number")
44
+ return null;
45
+ const latest = cache.latest === null ? null : normalizeVersion(cache.latest);
46
+ const ttl = updateCacheTtlFor(latest);
47
+ if (ttl < 0)
48
+ return null;
49
+ if (options.now() - cache.checkedAt > ttl)
50
+ return null;
51
+ return { latest };
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function updateCacheTtlFor(latest) {
58
+ if (latest && compareVersions(latest, options.currentVersion) > 0) {
59
+ return updateCheckTtlMs(options.env, UPDATE_CHECK_TTL_MS);
60
+ }
61
+ return noUpdateCheckTtlMs(options.env, NO_UPDATE_CHECK_TTL_MS);
62
+ }
63
+ function writeUpdateCache(latest) {
64
+ try {
65
+ const cachePath = options.cachePath();
66
+ const dir = dirname(cachePath);
67
+ if (dir) {
68
+ options.ensureDirectory(dir);
69
+ options.writeTextFile(cachePath, `${JSON.stringify({ latest, checkedAt: options.now() }, null, 2)}\n`);
70
+ }
71
+ }
72
+ catch {
73
+ // Update checks should never affect normal CLI behavior.
74
+ }
75
+ }
76
+ return { getUpdateInfo };
77
+ }
78
+ function createDefaultUpdateAdapter() {
79
+ const registryUrl = npmRegistryUrl();
80
+ return createUpdateAdapter({
81
+ currentVersion: VERSION,
82
+ registryUrl,
83
+ env: process.env,
84
+ now: () => Date.now(),
85
+ cachePath: () => updateCachePath(),
86
+ fileExists: existsSync,
87
+ readTextFile: (path) => readFileSync(path, "utf8"),
88
+ writeTextFile: writeFileSync,
89
+ ensureDirectory: (path) => mkdirSync(path, { recursive: true }),
90
+ fetchLatestVersion: (url) => fetchLatestNpmVersion(url)
91
+ });
92
+ }
93
+ async function fetchLatestNpmVersion(registryUrl) {
94
+ const controller = new AbortController();
95
+ const timeout = setTimeout(() => controller.abort(), 750);
96
+ try {
97
+ const response = await fetch(registryUrl, {
98
+ signal: controller.signal,
99
+ headers: { accept: "application/json", "user-agent": `artshelf/${VERSION}` }
100
+ });
101
+ if (!response.ok)
102
+ return null;
103
+ const body = await response.json();
104
+ if (!body || typeof body !== "object" || typeof body.version !== "string")
105
+ return null;
106
+ return normalizeVersion(body.version);
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ finally {
112
+ clearTimeout(timeout);
113
+ }
114
+ }
115
+ function normalizeVersion(version) {
116
+ return version.trim().replace(/^v/i, "");
117
+ }
118
+ function compareVersions(left, right) {
119
+ const a = parseVersion(left);
120
+ const b = parseVersion(right);
121
+ for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index += 1) {
122
+ const diff = (a.numbers[index] ?? 0) - (b.numbers[index] ?? 0);
123
+ if (diff !== 0)
124
+ return diff;
125
+ }
126
+ if (a.prerelease === b.prerelease)
127
+ return 0;
128
+ if (!a.prerelease)
129
+ return 1;
130
+ if (!b.prerelease)
131
+ return -1;
132
+ return a.prerelease.localeCompare(b.prerelease);
133
+ }
134
+ function parseVersion(version) {
135
+ const [main = "", prerelease = ""] = normalizeVersion(version).split("-", 2);
136
+ return {
137
+ numbers: main.split(".").map((part) => {
138
+ const parsed = Number.parseInt(part, 10);
139
+ return Number.isFinite(parsed) ? parsed : 0;
140
+ }),
141
+ prerelease
142
+ };
143
+ }