create-caspian-app 1.6.0 → 1.6.1-alpha
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/dist/.github/copilot-instructions.md +1 -1
- package/dist/AGENTS.md +2 -13
- package/dist/index.js +1 -1
- package/dist/settings/browser_log.py +505 -536
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/bs-config.ts +0 -32
- package/dist/settings/utils.ts +0 -28
- package/dist/tests/test_health_route.py +42 -85
- package/package.json +1 -1
- package/dist/settings/dev-hold.ts +0 -223
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
{
|
|
2
|
-
"local": "http://localhost:5090",
|
|
3
|
-
"external": "http://192.168.1.
|
|
4
|
-
"ui": "http://localhost:3001",
|
|
5
|
-
"uiExternal": "http://192.168.1.
|
|
6
|
-
"backend": "http://localhost:5200"
|
|
1
|
+
{
|
|
2
|
+
"local": "http://localhost:5090",
|
|
3
|
+
"external": "http://192.168.1.62:5090",
|
|
4
|
+
"ui": "http://localhost:3001",
|
|
5
|
+
"uiExternal": "http://192.168.1.62:3001",
|
|
6
|
+
"backend": "http://localhost:5200"
|
|
7
7
|
}
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
endBrowserLogSession,
|
|
28
28
|
startBrowserLogSession,
|
|
29
29
|
} from "./dev-log-bridge.js";
|
|
30
|
-
import { evaluateDevHold, isDevHoldActive } from "./dev-hold.js";
|
|
31
30
|
|
|
32
31
|
const { __dirname } = getFileMeta();
|
|
33
32
|
const bs: BrowserSyncInstance = browserSync.create();
|
|
@@ -254,37 +253,6 @@ const changeCoordinator = new SettledBatchWorker<string>(
|
|
|
254
253
|
},
|
|
255
254
|
1500,
|
|
256
255
|
"bs-change-coordinator",
|
|
257
|
-
// The 1500 ms quiet period batches a human's burst-save, but an agent's gap
|
|
258
|
-
// between two edits is a tool round-trip and always exceeds it, so every edit
|
|
259
|
-
// would otherwise cost a full restart plus a reload of every open tab. While
|
|
260
|
-
// an agent holds the dev stack, keep queueing and drain the whole run at once.
|
|
261
|
-
() => isDevHoldActive(),
|
|
262
|
-
(deferring, pending) => {
|
|
263
|
-
if (deferring) {
|
|
264
|
-
const status = evaluateDevHold();
|
|
265
|
-
const owner = status.hold ? status.hold.owner : "agent";
|
|
266
|
-
console.log(
|
|
267
|
-
chalk.yellow(
|
|
268
|
-
`-> Dev hold active (${owner} editing); holding ${pending.size} change(s). ` +
|
|
269
|
-
"Run `npm run dev:resume` to reload now.",
|
|
270
|
-
),
|
|
271
|
-
);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const status = evaluateDevHold();
|
|
276
|
-
if (status.reason === "stale" || status.reason === "expired") {
|
|
277
|
-
console.log(
|
|
278
|
-
chalk.yellow(
|
|
279
|
-
`-> Dev hold ${status.reason === "stale" ? "went stale" : "hit its time cap"}; ` +
|
|
280
|
-
"resuming normal reloads.",
|
|
281
|
-
),
|
|
282
|
-
);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
console.log(chalk.cyan("-> Dev hold released; applying held changes..."));
|
|
287
|
-
},
|
|
288
256
|
);
|
|
289
257
|
|
|
290
258
|
function isIgnoredPublicPath(absPath: string): boolean {
|
package/dist/settings/utils.ts
CHANGED
|
@@ -143,29 +143,17 @@ export class DebouncedWorker {
|
|
|
143
143
|
* period has elapsed. `onSettled` only runs when no newer work is pending, so
|
|
144
144
|
* callers can safely perform one user-visible action (such as a browser
|
|
145
145
|
* reload) after the final batch instead of after every intermediate batch.
|
|
146
|
-
*
|
|
147
|
-
* An optional `shouldDefer` predicate suspends draining entirely without losing
|
|
148
|
-
* work: pending items keep accumulating and the quiet period re-arms, so once
|
|
149
|
-
* the predicate clears, everything collected in the meantime drains as a single
|
|
150
|
-
* batch with a single settle. That is what lets an agent's whole editing run
|
|
151
|
-
* cost one restart and one reload instead of one of each per edit.
|
|
152
146
|
*/
|
|
153
147
|
export class SettledBatchWorker<T> {
|
|
154
148
|
private timer: NodeJS.Timeout | null = null;
|
|
155
149
|
private pending = new Set<T>();
|
|
156
150
|
private running = false;
|
|
157
|
-
private deferring = false;
|
|
158
151
|
|
|
159
152
|
constructor(
|
|
160
153
|
private work: (items: ReadonlySet<T>) => Promise<void> | void,
|
|
161
154
|
private onSettled: () => Promise<void> | void,
|
|
162
155
|
private quietMs = 1200,
|
|
163
156
|
private name = "batch",
|
|
164
|
-
private shouldDefer?: (pending: ReadonlySet<T>) => boolean,
|
|
165
|
-
private onDeferStateChange?: (
|
|
166
|
-
deferring: boolean,
|
|
167
|
-
pending: ReadonlySet<T>,
|
|
168
|
-
) => void,
|
|
169
157
|
) {}
|
|
170
158
|
|
|
171
159
|
schedule(item: T) {
|
|
@@ -185,15 +173,6 @@ export class SettledBatchWorker<T> {
|
|
|
185
173
|
if (this.running) return;
|
|
186
174
|
if (this.pending.size === 0) return;
|
|
187
175
|
|
|
188
|
-
// Checked before anything is consumed, so a deferred batch keeps every
|
|
189
|
-
// queued item and simply waits for another quiet period to re-test.
|
|
190
|
-
if (this.shouldDefer?.(this.pending)) {
|
|
191
|
-
this.setDeferring(true);
|
|
192
|
-
this.armQuietPeriod();
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
this.setDeferring(false);
|
|
196
|
-
|
|
197
176
|
const batch = new Set(this.pending);
|
|
198
177
|
this.pending.clear();
|
|
199
178
|
this.running = true;
|
|
@@ -217,13 +196,6 @@ export class SettledBatchWorker<T> {
|
|
|
217
196
|
console.error(`[${this.name}] settle error:`, err);
|
|
218
197
|
}
|
|
219
198
|
}
|
|
220
|
-
|
|
221
|
-
/** Reports edges only, so a long hold logs once instead of every quiet period. */
|
|
222
|
-
private setDeferring(deferring: boolean) {
|
|
223
|
-
if (this.deferring === deferring) return;
|
|
224
|
-
this.deferring = deferring;
|
|
225
|
-
this.onDeferStateChange?.(deferring, this.pending);
|
|
226
|
-
}
|
|
227
199
|
}
|
|
228
200
|
|
|
229
201
|
export function createRestartableProcess(spec: {
|
|
@@ -1,85 +1,42 @@
|
|
|
1
|
-
"""Integration test for the app's HTTP surface via httpx2's ASGI transport.
|
|
2
|
-
|
|
3
|
-
`/health` is the simplest always-on route and exercises the full middleware
|
|
4
|
-
stack (session, CSRF, security headers, auth) end to end. We drive the ASGI
|
|
5
|
-
app directly with httpx2's AsyncClient over its ASGITransport rather than
|
|
6
|
-
Starlette's TestClient.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
Mirrors the guest branches of `AuthMiddleware` in the order it applies them.
|
|
45
|
-
Importing `main` has already run `configure_auth(build_auth_settings())`, so
|
|
46
|
-
the shared `Auth` instance carries this app's real policy.
|
|
47
|
-
"""
|
|
48
|
-
auth = Auth.get_instance()
|
|
49
|
-
if auth.is_public_route(path) or auth.is_auth_route(path):
|
|
50
|
-
return None
|
|
51
|
-
if auth.settings.is_role_based and auth.get_required_roles(path):
|
|
52
|
-
return auth.get_signin_redirect(path)
|
|
53
|
-
if auth.is_private_route(path):
|
|
54
|
-
return auth.get_signin_redirect(path)
|
|
55
|
-
return None
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_health_returns_ok():
|
|
59
|
-
redirect = _guest_redirect_for("/health")
|
|
60
|
-
if redirect is not None:
|
|
61
|
-
pytest.skip(f"/health is auth-gated by this app's auth policy (redirects to {redirect})")
|
|
62
|
-
response = _get_sync("/health")
|
|
63
|
-
assert response.status_code == 200
|
|
64
|
-
assert response.json() == {"status": "ok"}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_health_carries_security_headers():
|
|
68
|
-
# Runs whatever the policy decides: the headers are attached to the redirect
|
|
69
|
-
# too, because SecurityHeadersMiddleware sits outside AuthMiddleware.
|
|
70
|
-
response = _get_sync("/health")
|
|
71
|
-
assert response.headers.get("x-content-type-options") == "nosniff"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_unknown_route_matches_auth_policy():
|
|
75
|
-
"""An unknown path 404s when it is reachable, and redirects when it is not."""
|
|
76
|
-
response = _get_sync(UNKNOWN_PATH)
|
|
77
|
-
redirect = _guest_redirect_for(UNKNOWN_PATH)
|
|
78
|
-
|
|
79
|
-
if redirect is None:
|
|
80
|
-
# Public-first: nothing guards the path, so routing answers with the 404 page.
|
|
81
|
-
assert response.status_code == 404
|
|
82
|
-
else:
|
|
83
|
-
# All-private: the guest never reaches the router.
|
|
84
|
-
assert response.status_code == 303
|
|
85
|
-
assert response.headers.get("location") == redirect
|
|
1
|
+
"""Integration test for the app's HTTP surface via httpx2's ASGI transport.
|
|
2
|
+
|
|
3
|
+
`/health` is the simplest always-on route and exercises the full middleware
|
|
4
|
+
stack (session, CSRF, security headers, auth) end to end. We drive the ASGI
|
|
5
|
+
app directly with httpx2's AsyncClient over its ASGITransport rather than
|
|
6
|
+
Starlette's TestClient.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
|
|
13
|
+
import main
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _get(path: str) -> httpx2.Response:
|
|
17
|
+
# ASGITransport speaks to the app in-process; no server/socket required.
|
|
18
|
+
transport = httpx2.ASGITransport(app=main.app)
|
|
19
|
+
async with httpx2.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
|
20
|
+
return await client.get(path)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_sync(path: str) -> httpx2.Response:
|
|
24
|
+
# Run the async request from a plain sync test (no pytest-asyncio needed).
|
|
25
|
+
return asyncio.run(_get(path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_health_returns_ok():
|
|
29
|
+
response = _get_sync("/health")
|
|
30
|
+
assert response.status_code == 200
|
|
31
|
+
assert response.json() == {"status": "ok"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_health_carries_security_headers():
|
|
35
|
+
response = _get_sync("/health")
|
|
36
|
+
# SecurityHeadersMiddleware attaches baseline browser headers.
|
|
37
|
+
assert response.headers.get("x-content-type-options") == "nosniff"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_unknown_route_is_404():
|
|
41
|
+
response = _get_sync("/definitely-not-a-real-route")
|
|
42
|
+
assert response.status_code == 404
|
package/package.json
CHANGED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev-stack reload hold.
|
|
3
|
-
*
|
|
4
|
-
* The file watcher cannot tell an agent's write from a human's save — chokidar
|
|
5
|
-
* sees an inode change, not a writer. So the agent declares itself instead: a
|
|
6
|
-
* hold file marks "an agent is mid-run", and while it is active the change
|
|
7
|
-
* coordinator in `bs-config.ts` keeps queueing changes without restarting the
|
|
8
|
-
* Python server or reloading the browser. Releasing the hold drains the whole
|
|
9
|
-
* run as ONE restart and ONE reload.
|
|
10
|
-
*
|
|
11
|
-
* Why this matters: the coordinator batches on a 1500 ms quiet period, which is
|
|
12
|
-
* tuned for a human's burst-save. An agent's gap between two edits is a tool
|
|
13
|
-
* round-trip — always wider than that window — so without a hold every single
|
|
14
|
-
* edit costs a full process restart plus a reload of every open tab, and each
|
|
15
|
-
* reload re-runs the route's Prisma queries against a pool that was just
|
|
16
|
-
* discarded. Against a remote database that is the churn this file prevents.
|
|
17
|
-
*
|
|
18
|
-
* The signal is set by Claude Code hooks (`PreToolUse` acquires and refreshes,
|
|
19
|
-
* `Stop`/`SessionEnd` release), so it does not depend on a model remembering to
|
|
20
|
-
* call anything. The manual escape hatches are `npm run dev:hold`,
|
|
21
|
-
* `npm run dev:resume`, and `npm run dev:hold:status`.
|
|
22
|
-
*
|
|
23
|
-
* This module is deliberately dependency-free and written in erasable-only
|
|
24
|
-
* TypeScript so Node can run it directly (`node settings/dev-hold.ts acquire`)
|
|
25
|
-
* with a cold start fast enough to sit in front of every agent edit.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
29
|
-
import { dirname, join } from "node:path";
|
|
30
|
-
import { fileURLToPath } from "node:url";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* A hold that stops being refreshed is treated as abandoned. This is the valve
|
|
34
|
-
* that keeps a crashed agent or a killed session from freezing the dev stack:
|
|
35
|
-
* the worst case degrades to today's behaviour, never to a dead server.
|
|
36
|
-
*/
|
|
37
|
-
export const STALE_HOLD_MS = 120_000;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Even a continuously refreshed hold drains eventually, so a very long agent run
|
|
41
|
-
* still gets periodic restarts instead of none at all.
|
|
42
|
-
*/
|
|
43
|
-
export const MAX_HOLD_MS = 600_000;
|
|
44
|
-
|
|
45
|
-
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
46
|
-
|
|
47
|
-
/** Resolved from this file, not from `cwd`, so hook shells cannot misplace it. */
|
|
48
|
-
export const WORKSPACE_ROOT = join(MODULE_DIR, "..");
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Lives in `.casp/` on purpose: `npm run dev` deletes that directory at startup,
|
|
52
|
-
* so a fresh dev stack can never inherit a stale hold from a previous session.
|
|
53
|
-
*
|
|
54
|
-
* Resolved per call rather than at import so `CASPIAN_DEV_HOLD_PATH` can point a
|
|
55
|
-
* test (or a second stack) at its own file instead of the live one.
|
|
56
|
-
*/
|
|
57
|
-
export function getHoldPath(): string {
|
|
58
|
-
return (
|
|
59
|
-
process.env.CASPIAN_DEV_HOLD_PATH ||
|
|
60
|
-
join(WORKSPACE_ROOT, ".casp", "dev-hold.json")
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export type DevHold = {
|
|
65
|
-
/** Who asked for the hold, for the terminal message only. */
|
|
66
|
-
owner: string;
|
|
67
|
-
/** Process that acquired it, for debugging a hold nobody expected. */
|
|
68
|
-
pid: number;
|
|
69
|
-
/** First acquire — drives the absolute cap. */
|
|
70
|
-
acquiredAt: number;
|
|
71
|
-
/** Most recent acquire — drives the stale check. */
|
|
72
|
-
touchedAt: number;
|
|
73
|
-
/** How many edits this run has queued, so the terminal can show progress. */
|
|
74
|
-
edits: number;
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export type DevHoldStatus =
|
|
78
|
-
| { active: false; reason: "none"; hold: null }
|
|
79
|
-
| { active: false; reason: "stale" | "expired"; hold: DevHold }
|
|
80
|
-
| { active: true; reason: "held"; hold: DevHold };
|
|
81
|
-
|
|
82
|
-
function isDevHold(value: unknown): value is DevHold {
|
|
83
|
-
if (typeof value !== "object" || value === null) return false;
|
|
84
|
-
const candidate = value as Partial<DevHold>;
|
|
85
|
-
return (
|
|
86
|
-
typeof candidate.owner === "string" &&
|
|
87
|
-
typeof candidate.pid === "number" &&
|
|
88
|
-
typeof candidate.acquiredAt === "number" &&
|
|
89
|
-
typeof candidate.touchedAt === "number" &&
|
|
90
|
-
typeof candidate.edits === "number"
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Returns the parsed hold, or null when absent or unreadable. */
|
|
95
|
-
export function readDevHold(): DevHold | null {
|
|
96
|
-
try {
|
|
97
|
-
const parsed: unknown = JSON.parse(readFileSync(getHoldPath(), "utf-8"));
|
|
98
|
-
return isDevHold(parsed) ? parsed : null;
|
|
99
|
-
} catch {
|
|
100
|
-
// Missing, half-written, or corrupt all mean the same thing to a caller:
|
|
101
|
-
// there is no hold it can trust, so fall through to normal reloading.
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Decide whether the coordinator should defer. Both expiry rules fail open —
|
|
108
|
-
* an unreadable, stale, or over-long hold reports inactive rather than blocking
|
|
109
|
-
* the dev stack forever.
|
|
110
|
-
*/
|
|
111
|
-
export function evaluateDevHold(now: number = Date.now()): DevHoldStatus {
|
|
112
|
-
const hold = readDevHold();
|
|
113
|
-
if (!hold) return { active: false, reason: "none", hold: null };
|
|
114
|
-
|
|
115
|
-
if (now - hold.touchedAt > STALE_HOLD_MS) {
|
|
116
|
-
return { active: false, reason: "stale", hold };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (now - hold.acquiredAt > MAX_HOLD_MS) {
|
|
120
|
-
return { active: false, reason: "expired", hold };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return { active: true, reason: "held", hold };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** True when reloads should be deferred right now. */
|
|
127
|
-
export function isDevHoldActive(now: number = Date.now()): boolean {
|
|
128
|
-
return evaluateDevHold(now).active;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Create the hold, or refresh an existing one. Refreshing keeps `acquiredAt` so
|
|
133
|
-
* the absolute cap measures the whole run rather than restarting on every edit.
|
|
134
|
-
*
|
|
135
|
-
* A capped-out hold is deliberately still carried over. If a new edit started a
|
|
136
|
-
* fresh window instead, an agent editing steadily would re-hold before the
|
|
137
|
-
* coordinator's next 1500 ms tick and the cap would reset forever without ever
|
|
138
|
-
* forcing a drain. Carrying it keeps the run expired — so reloads resume — until
|
|
139
|
-
* an explicit release ends the run. A stale hold is a different case: nothing
|
|
140
|
-
* refreshed it, so its owner is gone and the next edit legitimately starts over.
|
|
141
|
-
*/
|
|
142
|
-
export function acquireDevHold(
|
|
143
|
-
owner: string = "agent",
|
|
144
|
-
now: number = Date.now(),
|
|
145
|
-
): DevHold {
|
|
146
|
-
const previous = evaluateDevHold(now);
|
|
147
|
-
const carryOver =
|
|
148
|
-
previous.reason === "held" || previous.reason === "expired"
|
|
149
|
-
? previous.hold
|
|
150
|
-
: null;
|
|
151
|
-
|
|
152
|
-
const hold: DevHold = {
|
|
153
|
-
owner,
|
|
154
|
-
pid: process.pid,
|
|
155
|
-
acquiredAt: carryOver ? carryOver.acquiredAt : now,
|
|
156
|
-
touchedAt: now,
|
|
157
|
-
edits: carryOver ? carryOver.edits + 1 : 1,
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
mkdirSync(dirname(getHoldPath()), { recursive: true });
|
|
161
|
-
writeFileSync(getHoldPath(), `${JSON.stringify(hold, null, 2)}\n`, "utf-8");
|
|
162
|
-
return hold;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Drop the hold. Idempotent, so a duplicate `Stop` hook is harmless. */
|
|
166
|
-
export function releaseDevHold(): DevHold | null {
|
|
167
|
-
const hold = readDevHold();
|
|
168
|
-
rmSync(getHoldPath(), { force: true });
|
|
169
|
-
return hold;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function describeStatus(status: DevHoldStatus): string {
|
|
173
|
-
if (status.reason === "none") return "inactive - no hold file";
|
|
174
|
-
|
|
175
|
-
const ageSeconds = Math.round((Date.now() - status.hold.acquiredAt) / 1000);
|
|
176
|
-
const summary = `owner=${status.hold.owner} pid=${status.hold.pid} edits=${status.hold.edits} age=${ageSeconds}s`;
|
|
177
|
-
|
|
178
|
-
if (status.reason === "stale") {
|
|
179
|
-
return `inactive - hold went stale (no refresh for over ${STALE_HOLD_MS / 1000}s); ${summary}`;
|
|
180
|
-
}
|
|
181
|
-
if (status.reason === "expired") {
|
|
182
|
-
return `inactive - hold passed the ${MAX_HOLD_MS / 1000}s cap; ${summary}`;
|
|
183
|
-
}
|
|
184
|
-
return `ACTIVE - browser reloads and Python restarts are deferred; ${summary}`;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function runCli(argv: string[]): number {
|
|
188
|
-
const command = argv[0] ?? "status";
|
|
189
|
-
const quiet = argv.includes("--quiet");
|
|
190
|
-
const log = (message: string) => {
|
|
191
|
-
if (!quiet) console.log(message);
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
if (command === "acquire") {
|
|
195
|
-
const hold = acquireDevHold(process.env.CASPIAN_DEV_HOLD_OWNER || "agent");
|
|
196
|
-
log(`[dev-hold] Held after ${hold.edits} edit(s); reloads deferred.`);
|
|
197
|
-
return 0;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (command === "release") {
|
|
201
|
-
const hold = releaseDevHold();
|
|
202
|
-
log(
|
|
203
|
-
hold
|
|
204
|
-
? `[dev-hold] Released after ${hold.edits} edit(s); the dev stack will reload once.`
|
|
205
|
-
: "[dev-hold] No hold was active.",
|
|
206
|
-
);
|
|
207
|
-
return 0;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (command === "status") {
|
|
211
|
-
console.log(`[dev-hold] ${describeStatus(evaluateDevHold())}`);
|
|
212
|
-
return 0;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
console.error(`[dev-hold] Unknown command: ${command}`);
|
|
216
|
-
console.error("[dev-hold] Usage: dev-hold.ts <acquire|release|status> [--quiet]");
|
|
217
|
-
return 1;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Only act as a CLI when executed directly; `bs-config.ts` imports the helpers.
|
|
221
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
222
|
-
process.exit(runCli(process.argv.slice(2)));
|
|
223
|
-
}
|