@devwithdavid/ledger 0.1.4 → 0.1.5
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/LEDGER.md +28 -0
- package/README.md +7 -0
- package/dist/cli/commands/agents.js +28 -2
- package/dist/cli/commands/catchup.js +19 -2
- package/dist/cli/commands/clerk.js +9 -1
- package/dist/cli/commands/update.js +43 -0
- package/dist/cli/index.js +3 -25
- package/dist/db/migrations/0002_project_herdr_workspace.js +2 -1
- package/dist/lib/herdr.js +3 -20
- package/dist/lib/package-info.js +27 -0
- package/dist/lib/update-check.js +128 -0
- package/package.json +1 -1
package/LEDGER.md
CHANGED
|
@@ -170,6 +170,15 @@ repeating the same failure under every idle agent. `--json` includes the
|
|
|
170
170
|
same data as an `idle` array (`agent`, `pane_tail`, `pane_read_error`) plus
|
|
171
171
|
top-level `idle_pane_read_note` for the whole-socket case.
|
|
172
172
|
|
|
173
|
+
`catchup` also checks (item 42) whether a newer `@devwithdavid/ledger` npm
|
|
174
|
+
version exists — a plain-text notice after the normal output in
|
|
175
|
+
human-readable mode, or the top-level `update_available` JSON key
|
|
176
|
+
(`{current, latest}` or `null`) in `--json` mode, so `--json` output stays
|
|
177
|
+
valid JSON either way. The check is cached for 6h in
|
|
178
|
+
`$LEDGER_HOME/update-check.json` and degrades silently on any failure
|
|
179
|
+
(offline, timeout, malformed response) — it never delays or breaks
|
|
180
|
+
`catchup`. Run `ledger update` to actually upgrade.
|
|
181
|
+
|
|
173
182
|
### First-clerk claiming
|
|
174
183
|
|
|
175
184
|
```sh
|
|
@@ -181,6 +190,25 @@ ledger clerk status
|
|
|
181
190
|
`--force` is passed. Do this once per new/resumed clerk session before
|
|
182
191
|
dispatching anything.
|
|
183
192
|
|
|
193
|
+
`claim` runs the same cached update-availability check as `catchup` (item
|
|
194
|
+
42); since `claim` has no `--json` mode, a matching newer version prints
|
|
195
|
+
as a plain-text line after the claim's JSON output.
|
|
196
|
+
|
|
197
|
+
### Updating ledger
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
ledger update
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Checks npm for the latest `@devwithdavid/ledger` version (always fresh —
|
|
204
|
+
never the 6h cache `claim`/`catchup` use, since this is an explicit user
|
|
205
|
+
action). Already up to date: says so and exits. Newer version available:
|
|
206
|
+
runs `npm install -g @devwithdavid/ledger@latest` immediately (no
|
|
207
|
+
confirmation prompt) and reports old → new version. Unlike the passive
|
|
208
|
+
notice above, failures here (registry unreachable, npm install error) are
|
|
209
|
+
never swallowed — this is a foreground action, so they're reported
|
|
210
|
+
plainly and the command exits non-zero.
|
|
211
|
+
|
|
184
212
|
### Registering a project
|
|
185
213
|
|
|
186
214
|
```sh
|
package/README.md
CHANGED
|
@@ -68,6 +68,13 @@ herdr always runs whatever's currently in the installed package's `dist/`,
|
|
|
68
68
|
so `npm update -g @devwithdavid/ledger` picks up new releases (including
|
|
69
69
|
watcher changes) on the next event.
|
|
70
70
|
|
|
71
|
+
**Or just run `ledger update`** — checks npm for a newer version and, if
|
|
72
|
+
one exists, runs the install for you (equivalent to `npm install -g
|
|
73
|
+
@devwithdavid/ledger@latest`). `ledger claim` and `ledger catchup` also
|
|
74
|
+
print a one-line notice when a newer version is available (checked at
|
|
75
|
+
most every 6 hours, silently skipped if npm is unreachable), so you don't
|
|
76
|
+
have to think to check.
|
|
77
|
+
|
|
71
78
|
## Starting a clerk session
|
|
72
79
|
|
|
73
80
|
You don't run `ledger` commands yourself day to day — you talk to **the
|
|
@@ -426,15 +426,41 @@ function deriveLabel(task) {
|
|
|
426
426
|
const truncated = raw.slice(0, 32).replace(/-+$/g, "");
|
|
427
427
|
return truncated || "task";
|
|
428
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* True only if `workspaceId` still exists AND is still the workspace
|
|
431
|
+
* belonging to `project` — not just that the id resolves to *some*
|
|
432
|
+
* workspace. Workspace ids are recycled by herdr (e.g. after a herdr
|
|
433
|
+
* restart resets its allocation), so a stale stored id can collide with an
|
|
434
|
+
* unrelated, freshly-created workspace that happens to reuse the same id.
|
|
435
|
+
* Workspaces are created with `label: project.name` (see openDispatchPane
|
|
436
|
+
* below), so comparing the label is how identity — not just existence — is
|
|
437
|
+
* verified.
|
|
438
|
+
*/
|
|
439
|
+
function workspaceBelongsToProject(workspaceId, project) {
|
|
440
|
+
try {
|
|
441
|
+
// quiet: this is a routine "is it still ours?" check, run on every
|
|
442
|
+
// dispatch — a missing/stale workspace is an expected, handled outcome,
|
|
443
|
+
// not noise worth printing to the terminal every time.
|
|
444
|
+
const workspace = herdr.getWorkspace(workspaceId, { quiet: true });
|
|
445
|
+
return workspace.label === project.name;
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
if (err instanceof herdr.HerdrError && err.code === "workspace_not_found") {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
throw err;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
429
454
|
/**
|
|
430
455
|
* One herdr workspace per project, not per dispatch (per the user — see
|
|
431
456
|
* DECISIONS.md): reuses the project's existing workspace as a new tab when
|
|
432
457
|
* one is already live, or creates it (and records it via the caller) when
|
|
433
458
|
* this is the project's first dispatch, or its previous workspace was
|
|
434
|
-
* closed (e.g. by the user) since the
|
|
459
|
+
* closed (e.g. by the user) or recycled to a different project since the
|
|
460
|
+
* last dispatch.
|
|
435
461
|
*/
|
|
436
462
|
function openDispatchPane(project, cwd, label) {
|
|
437
|
-
if (project.herdr_workspace &&
|
|
463
|
+
if (project.herdr_workspace && workspaceBelongsToProject(project.herdr_workspace, project)) {
|
|
438
464
|
const tab = herdr.createTab({
|
|
439
465
|
workspace: project.herdr_workspace,
|
|
440
466
|
cwd,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getDb } from "../../db/client.js";
|
|
2
2
|
import { HerdrError, readPane } from "../../lib/herdr.js";
|
|
3
|
+
import { packageVersion } from "../../lib/package-info.js";
|
|
4
|
+
import { checkForUpdate, formatUpdateNotice } from "../../lib/update-check.js";
|
|
3
5
|
import { printJson } from "../format.js";
|
|
4
6
|
import { getProjectByName } from "./projects.js";
|
|
5
7
|
/** Default tail length for each idle agent's pane read (A8 liveness triage). */
|
|
@@ -73,7 +75,7 @@ export function registerCatchupCommand(program) {
|
|
|
73
75
|
.option("--project <name>", "scope roadmap (and optionally agents) to one project")
|
|
74
76
|
.option("--idle-pane-lines <n>", "lines of pane tail to show per idle agent (A8 liveness triage); 0 disables pane reads", parseIdlePaneLinesOpt, IDLE_PANE_TAIL_DEFAULT_LINES)
|
|
75
77
|
.option("--json", "output as JSON (default: human-readable)")
|
|
76
|
-
.action((opts) => {
|
|
78
|
+
.action(async (opts) => {
|
|
77
79
|
const db = getDb();
|
|
78
80
|
const project = opts.project ? getProjectByName(opts.project) : undefined;
|
|
79
81
|
const firstClerk = db
|
|
@@ -135,7 +137,20 @@ export function registerCatchupCommand(program) {
|
|
|
135
137
|
if (firstClerk) {
|
|
136
138
|
db.prepare("UPDATE first_clerk SET last_seen = datetime('now') WHERE id = 1").run();
|
|
137
139
|
}
|
|
138
|
-
|
|
140
|
+
// Item 42: cached (6h), silent-on-failure update-availability check.
|
|
141
|
+
// Added as an explicit top-level key (`update_available`, null when
|
|
142
|
+
// there's nothing to report) rather than mixed into plain text, so
|
|
143
|
+
// --json stays valid JSON in both cases.
|
|
144
|
+
const updateAvailable = await checkForUpdate(packageVersion());
|
|
145
|
+
const summary = {
|
|
146
|
+
since,
|
|
147
|
+
blocked,
|
|
148
|
+
idle,
|
|
149
|
+
idle_pane_read_note: idlePaneGlobalNote,
|
|
150
|
+
events,
|
|
151
|
+
roadmap,
|
|
152
|
+
update_available: updateAvailable,
|
|
153
|
+
};
|
|
139
154
|
if (opts.json) {
|
|
140
155
|
printJson(summary);
|
|
141
156
|
return;
|
|
@@ -170,5 +185,7 @@ export function registerCatchupCommand(program) {
|
|
|
170
185
|
const indent = r.parent_id ? " " : " ";
|
|
171
186
|
console.log(`${indent}#${r.id} [${r.status}] ${r.title}`);
|
|
172
187
|
}
|
|
188
|
+
if (updateAvailable)
|
|
189
|
+
console.log(`\n${formatUpdateNotice(updateAvailable)}`);
|
|
173
190
|
});
|
|
174
191
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getDb, suppressNextClerkHeartbeat } from "../../db/client.js";
|
|
2
2
|
import * as herdr from "../../lib/herdr.js";
|
|
3
|
+
import { packageVersion } from "../../lib/package-info.js";
|
|
4
|
+
import { checkForUpdate, formatUpdateNotice } from "../../lib/update-check.js";
|
|
3
5
|
import { printJson } from "../format.js";
|
|
4
6
|
const STALE_AFTER_HOURS = 12;
|
|
5
7
|
export function registerClerkCommands(program) {
|
|
@@ -11,7 +13,7 @@ export function registerClerkCommands(program) {
|
|
|
11
13
|
.requiredOption("--session-id <id>", "this clerk session's id")
|
|
12
14
|
.requiredOption("--herdr-pane <id>", "this clerk's own herdr pane id")
|
|
13
15
|
.option("--force", "claim even if the existing claim is not stale")
|
|
14
|
-
.action((opts) => {
|
|
16
|
+
.action(async (opts) => {
|
|
15
17
|
const db = getDb();
|
|
16
18
|
const existing = db
|
|
17
19
|
.prepare("SELECT * FROM first_clerk WHERE id = 1")
|
|
@@ -38,6 +40,12 @@ export function registerClerkCommands(program) {
|
|
|
38
40
|
// The claim is durable now; the rename below is cosmetic only.
|
|
39
41
|
renameClaimantWorkspace(opts.herdrPane);
|
|
40
42
|
printJson(row);
|
|
43
|
+
// Item 42: cached (6h), silent-on-failure update-availability notice.
|
|
44
|
+
// `claim` has no --json mode, so a plain-text line after the JSON
|
|
45
|
+
// output is fine per the spec.
|
|
46
|
+
const update = await checkForUpdate(packageVersion());
|
|
47
|
+
if (update)
|
|
48
|
+
console.log(formatUpdateNotice(update));
|
|
41
49
|
});
|
|
42
50
|
clerk
|
|
43
51
|
.command("status")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { packageVersion, PACKAGE_NAME } from "../../lib/package-info.js";
|
|
3
|
+
import { fetchLatestVersionOrThrow, isNewerVersion } from "../../lib/update-check.js";
|
|
4
|
+
/**
|
|
5
|
+
* `ledger update` (item 42): an explicit, foreground user action, so unlike
|
|
6
|
+
* the passive claim/catchup notice this always checks fresh (never the 6h
|
|
7
|
+
* cache) and never degrades silently — every failure (the version check
|
|
8
|
+
* itself, or the npm install) is reported plainly and exits non-zero.
|
|
9
|
+
* No confirmation prompt: per the spec, running it at all is the user's
|
|
10
|
+
* confirmation.
|
|
11
|
+
*/
|
|
12
|
+
export function registerUpdateCommand(program) {
|
|
13
|
+
program
|
|
14
|
+
.command("update")
|
|
15
|
+
.description(`check for and install the latest ${PACKAGE_NAME} from npm`)
|
|
16
|
+
.action(async () => {
|
|
17
|
+
const current = packageVersion();
|
|
18
|
+
let latest;
|
|
19
|
+
try {
|
|
20
|
+
latest = await fetchLatestVersionOrThrow();
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
24
|
+
throw new Error(`couldn't check npm for the latest version: ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
if (!isNewerVersion(latest, current)) {
|
|
27
|
+
console.log(`Already up to date (${current}).`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
console.log(`Updating ${PACKAGE_NAME}: ${current} -> ${latest} ...`);
|
|
31
|
+
const res = spawnSync("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
stdio: "inherit",
|
|
34
|
+
});
|
|
35
|
+
if (res.error) {
|
|
36
|
+
throw new Error(`npm install failed: ${res.error.message}`);
|
|
37
|
+
}
|
|
38
|
+
if (res.status !== 0) {
|
|
39
|
+
throw new Error(`npm install exited with code ${res.status}`);
|
|
40
|
+
}
|
|
41
|
+
console.log(`Updated ${PACKAGE_NAME}: ${current} -> ${latest}.`);
|
|
42
|
+
});
|
|
43
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Must come first — see the comment in suppress-experimental-warnings.ts.
|
|
3
3
|
import "./suppress-experimental-warnings.js";
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
4
|
import { Command } from "commander";
|
|
8
5
|
import { registerAgentCommands } from "./commands/agents.js";
|
|
9
6
|
import { registerCatchupCommand } from "./commands/catchup.js";
|
|
@@ -13,29 +10,9 @@ import { registerEventCommands } from "./commands/events.js";
|
|
|
13
10
|
import { registerInitCommand } from "./commands/init.js";
|
|
14
11
|
import { registerProjectCommands } from "./commands/projects.js";
|
|
15
12
|
import { registerRoadmapCommands } from "./commands/roadmap.js";
|
|
13
|
+
import { registerUpdateCommand } from "./commands/update.js";
|
|
16
14
|
import { touchClerkHeartbeat } from "../db/client.js";
|
|
17
|
-
|
|
18
|
-
// package.json is the ONLY source of it. This entry compiles to
|
|
19
|
-
// dist/cli/index.js, two levels below the package root, so resolve the
|
|
20
|
-
// file relative to *this running code's own location* rather than the cwd
|
|
21
|
-
// (same approach as docs.ts): that works from any invocation directory, in
|
|
22
|
-
// a dev checkout, and in an npm install (the npm tarball carries
|
|
23
|
-
// package.json at its root — verified in the 0.1.2 tarball). Publishing
|
|
24
|
-
// bumps package.json, and since this reads package.json, the two can never
|
|
25
|
-
// desync — a literal here is a second copy and is forbidden.
|
|
26
|
-
// Graceful degradation: if the file can't be read or parsed (a corrupt
|
|
27
|
-
// install), report "unknown" instead of throwing — a version query must
|
|
28
|
-
// never crash the CLI (item 30, DECISIONS.md).
|
|
29
|
-
function packageVersion() {
|
|
30
|
-
const pkgJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
31
|
-
try {
|
|
32
|
-
const parsed = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
33
|
-
return parsed.version ?? "unknown";
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return "unknown";
|
|
37
|
-
}
|
|
38
|
-
}
|
|
15
|
+
import { packageVersion } from "../lib/package-info.js";
|
|
39
16
|
const program = new Command();
|
|
40
17
|
program
|
|
41
18
|
.name("ledger")
|
|
@@ -50,6 +27,7 @@ registerClerkCommands(program);
|
|
|
50
27
|
registerCatchupCommand(program);
|
|
51
28
|
registerDocsCommand(program);
|
|
52
29
|
registerInitCommand(program);
|
|
30
|
+
registerUpdateCommand(program);
|
|
53
31
|
program.exitOverride();
|
|
54
32
|
try {
|
|
55
33
|
await program.parseAsync(process.argv);
|
|
@@ -7,7 +7,8 @@ export const migration0002ProjectHerdrWorkspace = {
|
|
|
7
7
|
-- the id here; every later dispatch to the same project reuses it,
|
|
8
8
|
-- adding a new tab rather than a new workspace. NULL until a first
|
|
9
9
|
-- dispatch happens, and re-nulled/replaced if that workspace is found
|
|
10
|
-
-- closed (see src/lib/herdr.ts
|
|
10
|
+
-- closed or recycled to a different project (see src/lib/herdr.ts
|
|
11
|
+
-- getWorkspace and openDispatchPane's use of it).
|
|
11
12
|
ALTER TABLE projects ADD COLUMN herdr_workspace TEXT;
|
|
12
13
|
`,
|
|
13
14
|
};
|
package/dist/lib/herdr.js
CHANGED
|
@@ -105,10 +105,9 @@ export function closeWorkspace(workspaceId) {
|
|
|
105
105
|
/**
|
|
106
106
|
* Fetches a workspace's info (including its label). Throws HerdrError
|
|
107
107
|
* (e.g. `workspace_not_found`) when the id doesn't exist. Pass `quiet`
|
|
108
|
-
* when a missing workspace is an expected, handled outcome
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* the piped stderr.
|
|
108
|
+
* when a missing workspace is an expected, handled outcome: it suppresses
|
|
109
|
+
* herdr's raw error envelope being echoed to the terminal, while
|
|
110
|
+
* `throwHerdrFailure` still parses it from the piped stderr.
|
|
112
111
|
*/
|
|
113
112
|
export function getWorkspace(workspaceId, opts) {
|
|
114
113
|
const result = runHerdr(["workspace", "get", workspaceId], opts);
|
|
@@ -117,22 +116,6 @@ export function getWorkspace(workspaceId, opts) {
|
|
|
117
116
|
export function renameWorkspace(workspaceId, label) {
|
|
118
117
|
runHerdr(["workspace", "rename", workspaceId, label]);
|
|
119
118
|
}
|
|
120
|
-
/** True if `workspaceId` still exists (wasn't closed, e.g. by the user). */
|
|
121
|
-
export function workspaceExists(workspaceId) {
|
|
122
|
-
try {
|
|
123
|
-
// quiet: this is a routine "is it still there?" check, run on every
|
|
124
|
-
// dispatch — a missing workspace is an expected, handled outcome, not
|
|
125
|
-
// noise worth printing to the terminal every time.
|
|
126
|
-
runHerdr(["workspace", "get", workspaceId], { quiet: true });
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
catch (err) {
|
|
130
|
-
if (err instanceof HerdrError && err.code === "workspace_not_found") {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
throw err;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
119
|
/** Adds a new tab (with its own root pane) to an existing workspace at `cwd`. */
|
|
137
120
|
export function createTab(opts) {
|
|
138
121
|
const args = [
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
// The npm package name this CLI is published as — also the identity used
|
|
5
|
+
// to query the registry for the latest published version (item 42).
|
|
6
|
+
export const PACKAGE_NAME = "@devwithdavid/ledger";
|
|
7
|
+
// This file compiles to dist/lib/package-info.js, two levels below the
|
|
8
|
+
// package root — resolve package.json relative to *this running code's own
|
|
9
|
+
// location* rather than the cwd (same approach as docs.ts/init.ts), so it
|
|
10
|
+
// works from any invocation directory, in a dev checkout, and in an npm
|
|
11
|
+
// install (the npm tarball carries package.json at its root).
|
|
12
|
+
const PACKAGE_JSON_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
13
|
+
/**
|
|
14
|
+
* The running CLI's own version, read from package.json at runtime —
|
|
15
|
+
* package.json is the ONLY source of it (item 30, DECISIONS.md). Graceful
|
|
16
|
+
* degradation: an unreadable/unparseable package.json reports "unknown"
|
|
17
|
+
* instead of throwing — a version query must never crash the CLI.
|
|
18
|
+
*/
|
|
19
|
+
export function packageVersion() {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8"));
|
|
22
|
+
return parsed.version ?? "unknown";
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return "unknown";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ledgerHome } from "../db/client.js";
|
|
4
|
+
import { PACKAGE_NAME } from "./package-info.js";
|
|
5
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // item 42, user-specified
|
|
6
|
+
const REGISTRY_TIMEOUT_MS = 1500; // "short timeout (~1-2s)", item 42
|
|
7
|
+
function cachePath() {
|
|
8
|
+
return join(ledgerHome(), "update-check.json");
|
|
9
|
+
}
|
|
10
|
+
function isUpdateCache(value) {
|
|
11
|
+
const v = value;
|
|
12
|
+
return (typeof v === "object" &&
|
|
13
|
+
v !== null &&
|
|
14
|
+
typeof v.checkedAt === "string" &&
|
|
15
|
+
typeof v.latestVersion === "string");
|
|
16
|
+
}
|
|
17
|
+
/** Per-machine cache under $LEDGER_HOME — deliberately not ledger.db (item 42: this is local cache state, not shared ledger state). */
|
|
18
|
+
function readCache() {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(readFileSync(cachePath(), "utf8"));
|
|
21
|
+
return isUpdateCache(parsed) ? parsed : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function writeCache(cache) {
|
|
28
|
+
try {
|
|
29
|
+
writeFileSync(cachePath(), JSON.stringify(cache), "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Best-effort: a failed cache write must never surface — the notice
|
|
33
|
+
// check degrades silently on any failure (item 42).
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isFresh(cache) {
|
|
37
|
+
const age = Date.now() - new Date(cache.checkedAt).getTime();
|
|
38
|
+
return age >= 0 && age < CACHE_TTL_MS;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Queries the npm registry directly (not the `npm` CLI — no dependency on
|
|
42
|
+
* it being installed beyond what's needed to actually run the update) for
|
|
43
|
+
* the latest published version. Throws with a descriptive message on any
|
|
44
|
+
* failure — offline, timeout, non-2xx, malformed body; callers choose
|
|
45
|
+
* whether that should be silent (the passive notice, via
|
|
46
|
+
* `fetchLatestVersion` below) or surfaced (`ledger update`, via
|
|
47
|
+
* `fetchLatestVersionOrThrow`).
|
|
48
|
+
*/
|
|
49
|
+
async function fetchLatestVersionInternal() {
|
|
50
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
51
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
throw new Error(`npm registry returned HTTP ${res.status} for ${PACKAGE_NAME}`);
|
|
55
|
+
}
|
|
56
|
+
const body = await res.json();
|
|
57
|
+
const version = body?.version;
|
|
58
|
+
if (typeof version !== "string") {
|
|
59
|
+
throw new Error("npm registry response had no version field");
|
|
60
|
+
}
|
|
61
|
+
return version;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Silent variant for the passive claim/catchup notice: any failure —
|
|
65
|
+
* offline, timeout, non-2xx, malformed body — must degrade to "no notice
|
|
66
|
+
* this time", never throw (item 42).
|
|
67
|
+
*/
|
|
68
|
+
async function fetchLatestVersion() {
|
|
69
|
+
try {
|
|
70
|
+
return await fetchLatestVersionInternal();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Parses "x.y.z" into a 3-tuple; a missing/non-numeric part reads as 0. */
|
|
77
|
+
function parseVersion(v) {
|
|
78
|
+
const parts = v.split(".").map((p) => Number.parseInt(p, 10));
|
|
79
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0].map((n) => (Number.isNaN(n) ? 0 : n));
|
|
80
|
+
}
|
|
81
|
+
/** True when `a` is a properly-newer semver than `b` (not string comparison — "0.2.0" > "0.10.0" would be wrong as strings). */
|
|
82
|
+
export function isNewerVersion(a, b) {
|
|
83
|
+
const [aMaj, aMin, aPatch] = parseVersion(a);
|
|
84
|
+
const [bMaj, bMin, bPatch] = parseVersion(b);
|
|
85
|
+
if (aMaj !== bMaj)
|
|
86
|
+
return aMaj > bMaj;
|
|
87
|
+
if (aMin !== bMin)
|
|
88
|
+
return aMin > bMin;
|
|
89
|
+
return aPatch > bPatch;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Cached (6h TTL) check for a newer npm version than the one running.
|
|
93
|
+
* Returns null whenever no *newer* version is known to exist — the
|
|
94
|
+
* running version is already current, the registry lookup failed, or
|
|
95
|
+
* "unknown" (package.json unreadable) is running. Never throws (item 42:
|
|
96
|
+
* this backs the passive claim/catchup notice, which must degrade
|
|
97
|
+
* silently on any failure).
|
|
98
|
+
*/
|
|
99
|
+
export async function checkForUpdate(currentVersion) {
|
|
100
|
+
if (currentVersion === "unknown")
|
|
101
|
+
return null;
|
|
102
|
+
const cached = readCache();
|
|
103
|
+
let latest;
|
|
104
|
+
if (cached && isFresh(cached)) {
|
|
105
|
+
latest = cached.latestVersion;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
latest = await fetchLatestVersion();
|
|
109
|
+
if (latest)
|
|
110
|
+
writeCache({ checkedAt: new Date().toISOString(), latestVersion: latest });
|
|
111
|
+
}
|
|
112
|
+
if (!latest || !isNewerVersion(latest, currentVersion))
|
|
113
|
+
return null;
|
|
114
|
+
return { current: currentVersion, latest };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Always queries the registry fresh, bypassing the cache — for `ledger
|
|
118
|
+
* update`, an explicit user action where a stale cached answer would be
|
|
119
|
+
* wrong (item 42: "fresh check is fine here"). Throws (rather than
|
|
120
|
+
* degrading to null) since this is a foreground, user-invoked action:
|
|
121
|
+
* failures here must be visible, unlike the passive notice (item 42).
|
|
122
|
+
*/
|
|
123
|
+
export async function fetchLatestVersionOrThrow() {
|
|
124
|
+
return fetchLatestVersionInternal();
|
|
125
|
+
}
|
|
126
|
+
export function formatUpdateNotice(info) {
|
|
127
|
+
return `A new version of ledger is available: ${info.current} -> ${info.latest}. Run \`ledger update\` to upgrade.`;
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devwithdavid/ledger",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Personal agent-orchestration ledger: SQLite state store, CLI, and herdr watcher plugin.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "https://yggdrasil.thekartiks.com/chewbakartik/ledger.git",
|