@os-eco/overstory-cli 0.8.7 → 0.9.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.
- package/README.md +26 -8
- package/agents/coordinator.md +30 -6
- package/agents/lead.md +11 -1
- package/agents/ov-co-creation.md +90 -0
- package/package.json +1 -1
- package/src/agents/hooks-deployer.test.ts +9 -1
- package/src/agents/hooks-deployer.ts +2 -1
- package/src/agents/overlay.test.ts +26 -0
- package/src/agents/overlay.ts +31 -4
- package/src/canopy/client.test.ts +107 -0
- package/src/canopy/client.ts +179 -0
- package/src/commands/agents.ts +1 -1
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/clean.ts +1 -58
- package/src/commands/completions.test.ts +18 -6
- package/src/commands/completions.ts +40 -1
- package/src/commands/coordinator.test.ts +77 -4
- package/src/commands/coordinator.ts +304 -146
- package/src/commands/dashboard.ts +47 -10
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.ts +3 -1
- package/src/commands/ecosystem.test.ts +126 -1
- package/src/commands/ecosystem.ts +7 -53
- package/src/commands/feed.test.ts +117 -2
- package/src/commands/feed.ts +46 -30
- package/src/commands/group.test.ts +274 -155
- package/src/commands/group.ts +11 -5
- package/src/commands/init.test.ts +2 -1
- package/src/commands/init.ts +8 -0
- package/src/commands/log.test.ts +35 -0
- package/src/commands/log.ts +10 -6
- package/src/commands/logs.test.ts +423 -1
- package/src/commands/logs.ts +99 -104
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +177 -2
- package/src/commands/prime.ts +4 -2
- package/src/commands/sling.ts +23 -3
- package/src/commands/update.test.ts +1 -0
- package/src/commands/upgrade.test.ts +2 -0
- package/src/commands/upgrade.ts +1 -17
- package/src/commands/watch.test.ts +67 -1
- package/src/commands/watch.ts +13 -88
- package/src/config.test.ts +250 -0
- package/src/config.ts +43 -0
- package/src/doctor/agents.test.ts +72 -5
- package/src/doctor/agents.ts +10 -10
- package/src/doctor/consistency.test.ts +35 -0
- package/src/doctor/consistency.ts +7 -3
- package/src/doctor/dependencies.test.ts +58 -1
- package/src/doctor/dependencies.ts +4 -2
- package/src/doctor/providers.test.ts +41 -5
- package/src/doctor/types.ts +2 -1
- package/src/doctor/version.test.ts +106 -2
- package/src/doctor/version.ts +4 -2
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +158 -0
- package/src/e2e/init-sling-lifecycle.test.ts +4 -2
- package/src/errors.test.ts +350 -0
- package/src/events/tailer.test.ts +25 -0
- package/src/events/tailer.ts +8 -1
- package/src/index.ts +9 -1
- package/src/mail/store.test.ts +110 -0
- package/src/mail/store.ts +2 -1
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +4 -2
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/pi-guards.ts +2 -1
- package/src/runtimes/pi.test.ts +9 -9
- package/src/runtimes/pi.ts +6 -7
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +13 -4
- package/src/runtimes/sapling.ts +2 -1
- package/src/runtimes/types.ts +2 -2
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.ts +25 -4
- package/src/types.ts +65 -1
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +68 -0
- package/src/utils/pid.ts +45 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/watchdog/daemon.test.ts +255 -1
- package/src/watchdog/daemon.ts +87 -9
- package/src/watchdog/health.test.ts +15 -1
- package/src/watchdog/health.ts +1 -1
- package/src/watchdog/triage.test.ts +49 -9
- package/src/watchdog/triage.ts +21 -5
- package/templates/overlay.md.tmpl +2 -0
package/src/types.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface OverstoryConfig {
|
|
|
68
68
|
root: string; // Absolute path to target repo
|
|
69
69
|
canonicalBranch: string; // "main" | "develop"
|
|
70
70
|
qualityGates?: QualityGate[];
|
|
71
|
+
/** Default canopy profile name. Used when --profile is not explicitly passed to sling/coordinator. */
|
|
72
|
+
defaultProfile?: string;
|
|
71
73
|
};
|
|
72
74
|
agents: {
|
|
73
75
|
manifestPath: string; // Path to agent-manifest.json
|
|
@@ -103,6 +105,9 @@ export interface OverstoryConfig {
|
|
|
103
105
|
staleThresholdMs: number; // When to consider agent stale
|
|
104
106
|
zombieThresholdMs: number; // When to kill
|
|
105
107
|
nudgeIntervalMs: number; // Time between progressive nudge stages (default 60_000)
|
|
108
|
+
rpcTimeoutMs?: number; // Timeout for RPC getState() calls (default 5_000)
|
|
109
|
+
triageTimeoutMs?: number; // Timeout for Tier 1 AI triage calls (default 30_000)
|
|
110
|
+
maxEscalationLevel?: number; // Maximum escalation level before termination (default 3)
|
|
106
111
|
};
|
|
107
112
|
models: Partial<Record<string, ModelRef>>;
|
|
108
113
|
logging: {
|
|
@@ -163,6 +168,7 @@ export const SUPPORTED_CAPABILITIES = [
|
|
|
163
168
|
"reviewer",
|
|
164
169
|
"lead",
|
|
165
170
|
"merger",
|
|
171
|
+
"orchestrator",
|
|
166
172
|
"coordinator",
|
|
167
173
|
"supervisor",
|
|
168
174
|
"monitor",
|
|
@@ -193,6 +199,7 @@ export interface AgentSession {
|
|
|
193
199
|
escalationLevel: number; // Progressive nudge stage: 0=warn, 1=nudge, 2=escalate, 3=terminate
|
|
194
200
|
stalledSince: string | null; // ISO timestamp when agent first entered stalled state
|
|
195
201
|
transcriptPath: string | null; // Runtime-provided transcript JSONL path (decoupled from ~/.claude/)
|
|
202
|
+
promptVersion?: string | null; // Canopy prompt version used at sling time (e.g. "builder@17")
|
|
196
203
|
}
|
|
197
204
|
|
|
198
205
|
// === Agent Identity ===
|
|
@@ -224,7 +231,8 @@ export type MailProtocolType =
|
|
|
224
231
|
| "escalation"
|
|
225
232
|
| "health_check"
|
|
226
233
|
| "dispatch"
|
|
227
|
-
| "assign"
|
|
234
|
+
| "assign"
|
|
235
|
+
| "decision_gate";
|
|
228
236
|
|
|
229
237
|
/** All valid mail message types. */
|
|
230
238
|
export type MailMessageType = MailSemanticType | MailProtocolType;
|
|
@@ -243,6 +251,7 @@ export const MAIL_MESSAGE_TYPES: readonly MailMessageType[] = [
|
|
|
243
251
|
"health_check",
|
|
244
252
|
"dispatch",
|
|
245
253
|
"assign",
|
|
254
|
+
"decision_gate",
|
|
246
255
|
] as const;
|
|
247
256
|
|
|
248
257
|
export interface MailMessage {
|
|
@@ -327,6 +336,16 @@ export interface AssignPayload {
|
|
|
327
336
|
branch: string;
|
|
328
337
|
}
|
|
329
338
|
|
|
339
|
+
/** Agent pauses for a human-in-the-loop decision before proceeding. */
|
|
340
|
+
export interface DecisionGatePayload {
|
|
341
|
+
/** Options for the human decision-maker to choose from. */
|
|
342
|
+
options: string[];
|
|
343
|
+
/** Context explaining why the decision is needed. */
|
|
344
|
+
context: string;
|
|
345
|
+
/** Optional deadline for the decision (ISO timestamp). */
|
|
346
|
+
deadline?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
330
349
|
/** Maps protocol message types to their payload interfaces. */
|
|
331
350
|
export interface MailPayloadMap {
|
|
332
351
|
worker_done: WorkerDonePayload;
|
|
@@ -337,6 +356,7 @@ export interface MailPayloadMap {
|
|
|
337
356
|
health_check: HealthCheckPayload;
|
|
338
357
|
dispatch: DispatchPayload;
|
|
339
358
|
assign: AssignPayload;
|
|
359
|
+
decision_gate: DecisionGatePayload;
|
|
340
360
|
}
|
|
341
361
|
|
|
342
362
|
// === Overlay ===
|
|
@@ -355,6 +375,8 @@ export interface OverlayConfig {
|
|
|
355
375
|
capability: string;
|
|
356
376
|
/** Full content of the base agent definition file (Layer 1: role-specific HOW). */
|
|
357
377
|
baseDefinition: string;
|
|
378
|
+
/** Rendered profile content from canopy (Layer 2: deployment-specific WHAT KIND). Inserted between base definition and assignment. */
|
|
379
|
+
profileContent?: string;
|
|
358
380
|
/** Pre-fetched mulch expertise output to embed directly in the overlay. */
|
|
359
381
|
mulchExpertise?: string;
|
|
360
382
|
/** When true, lead agents should skip Phase 1 (scout) and go straight to Phase 2 (build). */
|
|
@@ -714,6 +736,48 @@ export interface MulchCompactResult {
|
|
|
714
736
|
message?: string;
|
|
715
737
|
}
|
|
716
738
|
|
|
739
|
+
// === Canopy CLI Results ===
|
|
740
|
+
|
|
741
|
+
/** A single section within a rendered canopy prompt. */
|
|
742
|
+
export interface CanopyPromptSection {
|
|
743
|
+
name: string;
|
|
744
|
+
body: string;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** Summary of a canopy prompt as returned by list/show. */
|
|
748
|
+
export interface CanopyPromptSummary {
|
|
749
|
+
id: string;
|
|
750
|
+
name: string;
|
|
751
|
+
version: number;
|
|
752
|
+
sections: CanopyPromptSection[];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Result from cn render — resolved prompt with all inheritance applied. */
|
|
756
|
+
export interface CanopyRenderResult {
|
|
757
|
+
success: boolean;
|
|
758
|
+
name: string;
|
|
759
|
+
version: number;
|
|
760
|
+
sections: CanopyPromptSection[];
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Result from cn validate — validation status and errors. */
|
|
764
|
+
export interface CanopyValidateResult {
|
|
765
|
+
success: boolean;
|
|
766
|
+
errors: string[];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Result from cn list — list of all prompts. */
|
|
770
|
+
export interface CanopyListResult {
|
|
771
|
+
success: boolean;
|
|
772
|
+
prompts: CanopyPromptSummary[];
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Result from cn show — single prompt record. */
|
|
776
|
+
export interface CanopyShowResult {
|
|
777
|
+
success: boolean;
|
|
778
|
+
prompt: CanopyPromptSummary;
|
|
779
|
+
}
|
|
780
|
+
|
|
717
781
|
// === Session Lifecycle (Checkpoint / Handoff / Continuity) ===
|
|
718
782
|
|
|
719
783
|
/**
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveOverstoryBin } from "./bin.ts";
|
|
3
|
+
|
|
4
|
+
describe("resolveOverstoryBin", () => {
|
|
5
|
+
test("returns a non-empty string", async () => {
|
|
6
|
+
const bin = await resolveOverstoryBin();
|
|
7
|
+
expect(typeof bin).toBe("string");
|
|
8
|
+
expect(bin.length).toBeGreaterThan(0);
|
|
9
|
+
});
|
|
10
|
+
});
|
package/src/utils/bin.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary resolution utilities.
|
|
3
|
+
*/
|
|
4
|
+
import { OverstoryError } from "../errors.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the path to the overstory binary for re-launching.
|
|
8
|
+
* Uses `which ov` first, then falls back to process.argv.
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveOverstoryBin(): Promise<string> {
|
|
11
|
+
try {
|
|
12
|
+
const proc = Bun.spawn(["which", "ov"], {
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "pipe",
|
|
15
|
+
});
|
|
16
|
+
const exitCode = await proc.exited;
|
|
17
|
+
if (exitCode === 0) {
|
|
18
|
+
const binPath = (await new Response(proc.stdout).text()).trim();
|
|
19
|
+
if (binPath.length > 0) {
|
|
20
|
+
return binPath;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// which not available or overstory not on PATH
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback: use the script that's currently running (process.argv[1])
|
|
28
|
+
const scriptPath = process.argv[1];
|
|
29
|
+
if (scriptPath) {
|
|
30
|
+
return scriptPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
throw new OverstoryError(
|
|
34
|
+
"Cannot resolve overstory binary path for background launch",
|
|
35
|
+
"WATCH_ERROR",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
7
|
+
import { clearDirectory, deleteFile, resetJsonFile, wipeSqliteDb } from "./fs.ts";
|
|
8
|
+
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await mkdtemp(join(tmpdir(), "ov-fs-test-"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await cleanupTempDir(tempDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("wipeSqliteDb", () => {
|
|
20
|
+
test("deletes main db and WAL/SHM companion files", async () => {
|
|
21
|
+
const dbPath = join(tempDir, "test-wipe.db");
|
|
22
|
+
const { Database } = await import("bun:sqlite");
|
|
23
|
+
const db = new Database(dbPath);
|
|
24
|
+
db.exec("PRAGMA journal_mode=WAL");
|
|
25
|
+
db.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
|
26
|
+
db.exec("INSERT INTO t VALUES (1)");
|
|
27
|
+
db.close();
|
|
28
|
+
|
|
29
|
+
expect(existsSync(dbPath)).toBe(true);
|
|
30
|
+
|
|
31
|
+
const result = await wipeSqliteDb(dbPath);
|
|
32
|
+
expect(result).toBe(true);
|
|
33
|
+
|
|
34
|
+
expect(existsSync(dbPath)).toBe(false);
|
|
35
|
+
expect(existsSync(`${dbPath}-wal`)).toBe(false);
|
|
36
|
+
expect(existsSync(`${dbPath}-shm`)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false when db file does not exist", async () => {
|
|
40
|
+
const dbPath = join(tempDir, "nonexistent.db");
|
|
41
|
+
const result = await wipeSqliteDb(dbPath);
|
|
42
|
+
expect(result).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("resetJsonFile", () => {
|
|
47
|
+
test("resets existing JSON file to empty array", async () => {
|
|
48
|
+
const filePath = join(tempDir, "test-reset.json");
|
|
49
|
+
await Bun.write(filePath, '[{"id":"1"},{"id":"2"}]');
|
|
50
|
+
|
|
51
|
+
const result = await resetJsonFile(filePath);
|
|
52
|
+
expect(result).toBe(true);
|
|
53
|
+
|
|
54
|
+
const content = await Bun.file(filePath).text();
|
|
55
|
+
expect(content).toBe("[]\n");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("returns false for nonexistent file", async () => {
|
|
59
|
+
const filePath = join(tempDir, "nonexistent.json");
|
|
60
|
+
const result = await resetJsonFile(filePath);
|
|
61
|
+
expect(result).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("clearDirectory", () => {
|
|
66
|
+
test("clears files from a directory", async () => {
|
|
67
|
+
const dirPath = join(tempDir, "clear-test");
|
|
68
|
+
await mkdir(dirPath, { recursive: true });
|
|
69
|
+
await writeFile(join(dirPath, "file1.txt"), "hello");
|
|
70
|
+
await writeFile(join(dirPath, "file2.txt"), "world");
|
|
71
|
+
|
|
72
|
+
const result = await clearDirectory(dirPath);
|
|
73
|
+
expect(result).toBe(true);
|
|
74
|
+
|
|
75
|
+
const entries = await readdir(dirPath);
|
|
76
|
+
expect(entries).toHaveLength(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns false for empty directory", async () => {
|
|
80
|
+
const dirPath = join(tempDir, "empty-dir");
|
|
81
|
+
await mkdir(dirPath, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const result = await clearDirectory(dirPath);
|
|
84
|
+
expect(result).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns false for nonexistent directory", async () => {
|
|
88
|
+
const result = await clearDirectory(join(tempDir, "no-such-dir"));
|
|
89
|
+
expect(result).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("recursively removes subdirectories", async () => {
|
|
93
|
+
const dirPath = join(tempDir, "nested-clear");
|
|
94
|
+
await mkdir(join(dirPath, "sub", "deep"), { recursive: true });
|
|
95
|
+
await writeFile(join(dirPath, "sub", "deep", "file.txt"), "data");
|
|
96
|
+
|
|
97
|
+
const result = await clearDirectory(dirPath);
|
|
98
|
+
expect(result).toBe(true);
|
|
99
|
+
|
|
100
|
+
const entries = await readdir(dirPath);
|
|
101
|
+
expect(entries).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("deleteFile", () => {
|
|
106
|
+
test("deletes an existing file", async () => {
|
|
107
|
+
const filePath = join(tempDir, "to-delete.txt");
|
|
108
|
+
await writeFile(filePath, "delete me");
|
|
109
|
+
|
|
110
|
+
const result = await deleteFile(filePath);
|
|
111
|
+
expect(result).toBe(true);
|
|
112
|
+
expect(existsSync(filePath)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns false for nonexistent file", async () => {
|
|
116
|
+
const result = await deleteFile(join(tempDir, "no-such-file.txt"));
|
|
117
|
+
expect(result).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/utils/fs.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem utility functions for cleanup and state management.
|
|
3
|
+
*/
|
|
4
|
+
import { readdir, rm, unlink } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Delete a SQLite database file and its WAL/SHM companions.
|
|
9
|
+
*/
|
|
10
|
+
export async function wipeSqliteDb(dbPath: string): Promise<boolean> {
|
|
11
|
+
const extensions = ["", "-wal", "-shm"];
|
|
12
|
+
let wiped = false;
|
|
13
|
+
for (const ext of extensions) {
|
|
14
|
+
try {
|
|
15
|
+
await unlink(`${dbPath}${ext}`);
|
|
16
|
+
if (ext === "") wiped = true;
|
|
17
|
+
} catch {
|
|
18
|
+
// File may not exist
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return wiped;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reset a JSON file to an empty array.
|
|
26
|
+
*/
|
|
27
|
+
export async function resetJsonFile(path: string): Promise<boolean> {
|
|
28
|
+
const file = Bun.file(path);
|
|
29
|
+
if (await file.exists()) {
|
|
30
|
+
await Bun.write(path, "[]\n");
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Clear all entries inside a directory but keep the directory itself.
|
|
38
|
+
*/
|
|
39
|
+
export async function clearDirectory(dirPath: string): Promise<boolean> {
|
|
40
|
+
try {
|
|
41
|
+
const entries = await readdir(dirPath);
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
await rm(join(dirPath, entry), { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
return entries.length > 0;
|
|
46
|
+
} catch {
|
|
47
|
+
// Directory may not exist
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Delete a single file if it exists.
|
|
54
|
+
*/
|
|
55
|
+
export async function deleteFile(path: string): Promise<boolean> {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(path);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cleanupTempDir } from "../test-helpers.ts";
|
|
6
|
+
import { readPidFile, removePidFile, writePidFile } from "./pid.ts";
|
|
7
|
+
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await mkdtemp(join(tmpdir(), "ov-pid-test-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await cleanupTempDir(tempDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("readPidFile", () => {
|
|
19
|
+
test("returns pid from valid file", async () => {
|
|
20
|
+
const pidPath = join(tempDir, "test.pid");
|
|
21
|
+
await Bun.write(pidPath, "12345\n");
|
|
22
|
+
const pid = await readPidFile(pidPath);
|
|
23
|
+
expect(pid).toBe(12345);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns null for nonexistent file", async () => {
|
|
27
|
+
const pid = await readPidFile(join(tempDir, "missing.pid"));
|
|
28
|
+
expect(pid).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns null for non-numeric content", async () => {
|
|
32
|
+
const pidPath = join(tempDir, "bad.pid");
|
|
33
|
+
await Bun.write(pidPath, "not-a-number\n");
|
|
34
|
+
const pid = await readPidFile(pidPath);
|
|
35
|
+
expect(pid).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns null for negative pid", async () => {
|
|
39
|
+
const pidPath = join(tempDir, "neg.pid");
|
|
40
|
+
await Bun.write(pidPath, "-1\n");
|
|
41
|
+
const pid = await readPidFile(pidPath);
|
|
42
|
+
expect(pid).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("writePidFile", () => {
|
|
47
|
+
test("roundtrip write then read", async () => {
|
|
48
|
+
const pidPath = join(tempDir, "roundtrip.pid");
|
|
49
|
+
await writePidFile(pidPath, 42);
|
|
50
|
+
const pid = await readPidFile(pidPath);
|
|
51
|
+
expect(pid).toBe(42);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("removePidFile", () => {
|
|
56
|
+
test("removes existing file", async () => {
|
|
57
|
+
const pidPath = join(tempDir, "remove.pid");
|
|
58
|
+
await Bun.write(pidPath, "99\n");
|
|
59
|
+
expect(await Bun.file(pidPath).exists()).toBe(true);
|
|
60
|
+
await removePidFile(pidPath);
|
|
61
|
+
expect(await Bun.file(pidPath).exists()).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("does not throw for nonexistent file", async () => {
|
|
65
|
+
await removePidFile(join(tempDir, "nope.pid"));
|
|
66
|
+
// No throw = pass
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/utils/pid.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PID file management for daemon processes.
|
|
3
|
+
*/
|
|
4
|
+
import { unlink } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read the PID from a PID file.
|
|
8
|
+
* Returns null if the file doesn't exist or can't be parsed.
|
|
9
|
+
*/
|
|
10
|
+
export async function readPidFile(pidFilePath: string): Promise<number | null> {
|
|
11
|
+
const file = Bun.file(pidFilePath);
|
|
12
|
+
const exists = await file.exists();
|
|
13
|
+
if (!exists) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const text = await file.text();
|
|
19
|
+
const pid = Number.parseInt(text.trim(), 10);
|
|
20
|
+
if (Number.isNaN(pid) || pid <= 0) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return pid;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Write a PID to a PID file.
|
|
31
|
+
*/
|
|
32
|
+
export async function writePidFile(pidFilePath: string, pid: number): Promise<void> {
|
|
33
|
+
await Bun.write(pidFilePath, `${pid}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove a PID file.
|
|
38
|
+
*/
|
|
39
|
+
export async function removePidFile(pidFilePath: string): Promise<void> {
|
|
40
|
+
try {
|
|
41
|
+
await unlink(pidFilePath);
|
|
42
|
+
} catch {
|
|
43
|
+
// File may already be gone — not an error
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseRelativeTime } from "./time.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseRelativeTime", () => {
|
|
5
|
+
test("parses '30s' as ~30 seconds ago", () => {
|
|
6
|
+
const before = Date.now();
|
|
7
|
+
const result = parseRelativeTime("30s");
|
|
8
|
+
const after = Date.now();
|
|
9
|
+
expect(result.getTime()).toBeGreaterThanOrEqual(before - 30 * 1000 - 50);
|
|
10
|
+
expect(result.getTime()).toBeLessThanOrEqual(after - 30 * 1000 + 50);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("parses '5m' as ~5 minutes ago", () => {
|
|
14
|
+
const before = Date.now();
|
|
15
|
+
const result = parseRelativeTime("5m");
|
|
16
|
+
const expected = before - 5 * 60 * 1000;
|
|
17
|
+
expect(Math.abs(result.getTime() - expected)).toBeLessThan(100);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("parses '2h' as ~2 hours ago", () => {
|
|
21
|
+
const before = Date.now();
|
|
22
|
+
const result = parseRelativeTime("2h");
|
|
23
|
+
const expected = before - 2 * 60 * 60 * 1000;
|
|
24
|
+
expect(Math.abs(result.getTime() - expected)).toBeLessThan(100);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("parses '1d' as ~1 day ago", () => {
|
|
28
|
+
const before = Date.now();
|
|
29
|
+
const result = parseRelativeTime("1d");
|
|
30
|
+
const expected = before - 24 * 60 * 60 * 1000;
|
|
31
|
+
expect(Math.abs(result.getTime() - expected)).toBeLessThan(100);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parses ISO 8601 timestamp", () => {
|
|
35
|
+
const result = parseRelativeTime("2026-01-15T10:30:00.000Z");
|
|
36
|
+
expect(result.toISOString()).toBe("2026-01-15T10:30:00.000Z");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns invalid Date for garbage input", () => {
|
|
40
|
+
const result = parseRelativeTime("not-a-time");
|
|
41
|
+
expect(Number.isNaN(result.getTime())).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time parsing utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse relative time formats like "1h", "30m", "2d", "10s" into a Date object.
|
|
7
|
+
* Falls back to parsing as ISO 8601 if not in relative format.
|
|
8
|
+
*/
|
|
9
|
+
export function parseRelativeTime(timeStr: string): Date {
|
|
10
|
+
const relativeMatch = /^(\d+)(s|m|h|d)$/.exec(timeStr);
|
|
11
|
+
if (relativeMatch) {
|
|
12
|
+
const value = Number.parseInt(relativeMatch[1] ?? "0", 10);
|
|
13
|
+
const unit = relativeMatch[2];
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
let offsetMs = 0;
|
|
16
|
+
|
|
17
|
+
switch (unit) {
|
|
18
|
+
case "s":
|
|
19
|
+
offsetMs = value * 1000;
|
|
20
|
+
break;
|
|
21
|
+
case "m":
|
|
22
|
+
offsetMs = value * 60 * 1000;
|
|
23
|
+
break;
|
|
24
|
+
case "h":
|
|
25
|
+
offsetMs = value * 60 * 60 * 1000;
|
|
26
|
+
break;
|
|
27
|
+
case "d":
|
|
28
|
+
offsetMs = value * 24 * 60 * 60 * 1000;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return new Date(now - offsetMs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Not a relative format, treat as ISO 8601
|
|
36
|
+
return new Date(timeStr);
|
|
37
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { fetchLatestVersion, getCurrentVersion, getInstalledVersion } from "./version.ts";
|
|
3
|
+
|
|
4
|
+
describe("getCurrentVersion", () => {
|
|
5
|
+
test("returns a semver string", async () => {
|
|
6
|
+
const version = await getCurrentVersion();
|
|
7
|
+
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("fetchLatestVersion", () => {
|
|
12
|
+
test("fetches a semver string from npm registry", async () => {
|
|
13
|
+
const version = await fetchLatestVersion("@os-eco/overstory-cli");
|
|
14
|
+
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("throws for nonexistent package", async () => {
|
|
18
|
+
await expect(fetchLatestVersion("@nonexistent/package-xyz-999")).rejects.toThrow();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("getInstalledVersion", () => {
|
|
23
|
+
test("returns version for an installed CLI (bun)", async () => {
|
|
24
|
+
const version = await getInstalledVersion("bun");
|
|
25
|
+
expect(version).not.toBeNull();
|
|
26
|
+
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("returns null for nonexistent CLI", async () => {
|
|
30
|
+
const version = await getInstalledVersion("nonexistent-cli-xyz-999");
|
|
31
|
+
expect(version).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version detection and npm registry utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the current installed version of overstory from package.json.
|
|
7
|
+
*/
|
|
8
|
+
export async function getCurrentVersion(): Promise<string> {
|
|
9
|
+
const pkgPath = new URL("../../package.json", import.meta.url);
|
|
10
|
+
const pkg = JSON.parse(await Bun.file(pkgPath).text()) as { version: string };
|
|
11
|
+
return pkg.version;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fetch the latest published version of an npm package from the registry.
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchLatestVersion(packageName: string): Promise<string> {
|
|
18
|
+
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Failed to fetch npm registry for ${packageName}: ${res.status} ${res.statusText}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const data = (await res.json()) as { version: string };
|
|
25
|
+
return data.version;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the installed version of a CLI tool by running `--version`.
|
|
30
|
+
* Tries `--version --json` first, then falls back to plain text.
|
|
31
|
+
*/
|
|
32
|
+
export async function getInstalledVersion(cli: string): Promise<string | null> {
|
|
33
|
+
// Try --version --json first
|
|
34
|
+
try {
|
|
35
|
+
const proc = Bun.spawn([cli, "--version", "--json"], {
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
});
|
|
39
|
+
const exitCode = await proc.exited;
|
|
40
|
+
if (exitCode === 0) {
|
|
41
|
+
const stdout = await new Response(proc.stdout).text();
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(stdout.trim()) as { version?: string };
|
|
44
|
+
if (data.version) return data.version;
|
|
45
|
+
} catch {
|
|
46
|
+
// Not valid JSON, fall through to plain text
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// CLI not found — fall through to plain text fallback
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fallback: --version plain text
|
|
54
|
+
try {
|
|
55
|
+
const proc = Bun.spawn([cli, "--version"], {
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
});
|
|
59
|
+
const exitCode = await proc.exited;
|
|
60
|
+
if (exitCode === 0) {
|
|
61
|
+
const stdout = await new Response(proc.stdout).text();
|
|
62
|
+
const match = stdout.match(/(\d+\.\d+\.\d+)/);
|
|
63
|
+
if (match?.[1]) return match[1];
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// CLI not found
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|