quotacap 0.0.22 → 0.0.23
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 +4 -6
- package/dist/adapters/grok.js +2 -2
- package/dist/adapters/pty.d.ts +18 -0
- package/dist/adapters/pty.js +124 -32
- package/dist/advisory/snapshot.d.ts +6 -0
- package/dist/advisory/types.d.ts +7 -0
- package/dist/cli/advise.js +72 -6
- package/dist/cli/clients.d.ts +10 -0
- package/dist/cli/index.js +13 -8
- package/dist/cli/init.d.ts +2 -0
- package/dist/cli/init.js +29 -0
- package/dist/cli/runtime.d.ts +21 -0
- package/dist/cli/runtime.js +169 -42
- package/dist/cli/snapshot-source.d.ts +1 -0
- package/dist/cli/snapshot-source.js +9 -0
- package/dist/cli/status.js +70 -4
- package/dist/cli/takeover.d.ts +64 -0
- package/dist/cli/takeover.js +175 -0
- package/dist/cli/update.d.ts +61 -0
- package/dist/cli/update.js +392 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +28 -0
- package/dist/http/server.d.ts +8 -0
- package/dist/http/server.js +40 -0
- package/dist/runtime/poll.d.ts +2 -0
- package/dist/runtime/poll.js +7 -0
- package/dist/runtime/service.js +19 -2
- package/dist/runtime/updates.d.ts +74 -0
- package/dist/runtime/updates.js +445 -0
- package/dist/runtime/versions.d.ts +2 -0
- package/dist/runtime/versions.js +26 -0
- package/dist/service/index.d.ts +7 -2
- package/dist/service/index.js +67 -11
- package/dist/service/macos.d.ts +4 -0
- package/dist/service/macos.js +61 -10
- package/dist/service/systemd.d.ts +40 -0
- package/dist/service/systemd.js +387 -0
- package/dist/src/adapters/grok.js +2 -2
- package/dist/src/adapters/pty.d.ts +18 -0
- package/dist/src/adapters/pty.js +124 -32
- package/dist/src/advisory/snapshot.d.ts +6 -0
- package/dist/src/advisory/types.d.ts +7 -0
- package/dist/src/cli/advise.js +72 -6
- package/dist/src/cli/clients.d.ts +10 -0
- package/dist/src/cli/index.js +13 -8
- package/dist/src/cli/init.d.ts +2 -0
- package/dist/src/cli/init.js +29 -0
- package/dist/src/cli/runtime.d.ts +21 -0
- package/dist/src/cli/runtime.js +169 -42
- package/dist/src/cli/snapshot-source.d.ts +1 -0
- package/dist/src/cli/snapshot-source.js +9 -0
- package/dist/src/cli/status.js +70 -4
- package/dist/src/cli/takeover.d.ts +64 -0
- package/dist/src/cli/takeover.js +175 -0
- package/dist/src/cli/update.d.ts +61 -0
- package/dist/src/cli/update.js +392 -0
- package/dist/src/config.d.ts +7 -0
- package/dist/src/config.js +28 -0
- package/dist/src/http/server.d.ts +8 -0
- package/dist/src/http/server.js +40 -0
- package/dist/src/runtime/poll.d.ts +2 -0
- package/dist/src/runtime/poll.js +7 -0
- package/dist/src/runtime/service.js +19 -2
- package/dist/src/runtime/updates.d.ts +74 -0
- package/dist/src/runtime/updates.js +445 -0
- package/dist/src/runtime/versions.d.ts +2 -0
- package/dist/src/runtime/versions.js +26 -0
- package/dist/src/service/index.d.ts +7 -2
- package/dist/src/service/index.js +67 -11
- package/dist/src/service/macos.d.ts +4 -0
- package/dist/src/service/macos.js +61 -10
- package/dist/src/service/systemd.d.ts +40 -0
- package/dist/src/service/systemd.js +387 -0
- package/dist/src/version.js +1 -1
- package/dist/src/webAssets.js +1 -1
- package/dist/src/webHtml.js +1 -1
- package/dist/tests/adapters/grok.test.js +10 -0
- package/dist/tests/adapters/pty-perms.test.d.ts +1 -0
- package/dist/tests/adapters/pty-perms.test.js +78 -0
- package/dist/tests/cli/advise.test.js +73 -0
- package/dist/tests/cli/cli.test.js +1 -1
- package/dist/tests/cli/init.test.d.ts +1 -0
- package/dist/tests/cli/init.test.js +55 -0
- package/dist/tests/cli/launcher.test.d.ts +1 -0
- package/dist/tests/cli/launcher.test.js +271 -0
- package/dist/tests/cli/status.test.js +113 -0
- package/dist/tests/cli/takeover.test.d.ts +1 -0
- package/dist/tests/cli/takeover.test.js +325 -0
- package/dist/tests/cli/update.test.d.ts +1 -0
- package/dist/tests/cli/update.test.js +382 -0
- package/dist/tests/runtime/cli.test.js +206 -6
- package/dist/tests/runtime/http.test.js +79 -0
- package/dist/tests/runtime/poll.test.js +28 -0
- package/dist/tests/runtime/updates.test.d.ts +1 -0
- package/dist/tests/runtime/updates.test.js +509 -0
- package/dist/tests/service/macos.test.js +103 -2
- package/dist/tests/service/systemd.test.d.ts +1 -0
- package/dist/tests/service/systemd.test.js +330 -0
- package/dist/tests/smoke/dashboard.e2e.d.ts +1 -0
- package/dist/tests/smoke/dashboard.e2e.js +142 -0
- package/dist/tests/smoke/playwright.config.d.ts +2 -0
- package/dist/tests/smoke/playwright.config.js +11 -0
- package/dist/tests/web/dashboard.e2e.js +1 -1
- package/dist/tests/web/header.test.js +26 -0
- package/dist/version.js +1 -1
- package/dist/web/src/components/Header.js +5 -1
- package/dist/web/src/state.d.ts +7 -0
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +2 -3
- package/web/dist/assets/{index-CyUkWdwJ.css → index-BEBqgvq8.css} +1 -1
- package/web/dist/assets/{index-PN4RpMKG.js → index-DZfttQgo.js} +1 -1
- package/web/dist/index.html +2 -2
- package/scripts/fix-pty-perms.mjs +0 -21
package/dist/cli/runtime.js
CHANGED
|
@@ -1,11 +1,156 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { VERSION } from "../version.js";
|
|
4
|
-
import {
|
|
4
|
+
import { ensureConfig, getDbPath } from "../config.js";
|
|
5
5
|
import { readClaim } from "../runtime/owner.js";
|
|
6
6
|
import { startService } from "../runtime/service.js";
|
|
7
7
|
import { createServiceClient, ServiceError, ServiceUnavailable, } from "../runtime/client.js";
|
|
8
|
-
import { runServiceCommand } from "../service/index.js";
|
|
8
|
+
import { isServiceInstalled, isServiceManaged, runServiceCommand, serviceSupported, } from "../service/index.js";
|
|
9
|
+
import { detectChannel, refreshUpdateCache } from "../runtime/updates.js";
|
|
10
|
+
import { checkSkew, execSkewWarning, formatWedged, olderCliWarning, takeoverManaged, takeoverUnmanaged, upgradedMessage, waitForHealthy, WedgedError, } from "./takeover.js";
|
|
11
|
+
// Shared `web` launcher handler: the `web` command and the bare `quotacap`
|
|
12
|
+
// invocation both run this. Decision order: healthy daemons open (with the
|
|
13
|
+
// skew matrix), registered-but-down services start, and only the last resort
|
|
14
|
+
// foreground-starts. Explicit `--foreground` and non-config `--port`
|
|
15
|
+
// overrides skip service management entirely.
|
|
16
|
+
export async function launchWeb(o, deps = {}) {
|
|
17
|
+
const start = deps.startService ?? startService;
|
|
18
|
+
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
|
|
19
|
+
const exit = deps.exit ?? process.exit;
|
|
20
|
+
const execService = deps.execService ?? ((args, opts) => runServiceCommand(args, opts ?? {}, {}));
|
|
21
|
+
const createClient = deps.createClient ?? ((c) => createServiceClient(c));
|
|
22
|
+
const isManaged = deps.isManaged ?? isServiceManaged;
|
|
23
|
+
const isInstalled = deps.isInstalled ?? isServiceInstalled;
|
|
24
|
+
const supported = deps.serviceSupported ?? serviceSupported;
|
|
25
|
+
const takeoverOpts = deps.takeoverOpts ?? {};
|
|
26
|
+
const checkUpdates = deps.checkUpdates ??
|
|
27
|
+
(() => refreshUpdateCache({ channel: detectChannel(), current: VERSION }));
|
|
28
|
+
const sleep = takeoverOpts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
29
|
+
const readyTimeoutMs = takeoverOpts.timeoutMs ?? 15000;
|
|
30
|
+
const { config: cfg } = await ensureConfig();
|
|
31
|
+
const port = o.port ? parseInt(o.port, 10) : cfg.port;
|
|
32
|
+
const dataDir = path.dirname(getDbPath());
|
|
33
|
+
// Passive daily update signal (writer only; the dashboard badge reads it).
|
|
34
|
+
await checkUpdates().catch(() => null);
|
|
35
|
+
// Services only ever bind the config port, so an explicit override names
|
|
36
|
+
// an ad-hoc listener: skip service management like --foreground does.
|
|
37
|
+
const foregroundOnly = !!o.foreground || (!!o.port && port !== cfg.port);
|
|
38
|
+
// 1. A healthy service answers: compare skew, take over when newer.
|
|
39
|
+
let health = null;
|
|
40
|
+
try {
|
|
41
|
+
health = await createClient({ port, timeoutMs: 2000 }).get("/health");
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (!(e instanceof ServiceUnavailable) && !(e instanceof ServiceError)) {
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
health = null;
|
|
48
|
+
}
|
|
49
|
+
if (health?.ok) {
|
|
50
|
+
const skew = checkSkew(health, VERSION, process.execPath);
|
|
51
|
+
if (skew === "match") {
|
|
52
|
+
await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (skew === "exec-only") {
|
|
56
|
+
console.error(execSkewWarning(String(health.exec)));
|
|
57
|
+
await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (skew === "cli-older") {
|
|
61
|
+
console.error(olderCliWarning(String(health.version ?? "unknown")));
|
|
62
|
+
await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// CLI newer than daemon: take over, then open.
|
|
66
|
+
const managed = await isManaged().catch(() => false);
|
|
67
|
+
if (managed) {
|
|
68
|
+
try {
|
|
69
|
+
const r = await takeoverManaged({
|
|
70
|
+
port,
|
|
71
|
+
health,
|
|
72
|
+
createClient,
|
|
73
|
+
execService,
|
|
74
|
+
...takeoverOpts,
|
|
75
|
+
});
|
|
76
|
+
console.error(r.message);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
80
|
+
exit(1);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const r = await takeoverUnmanaged({
|
|
88
|
+
port,
|
|
89
|
+
health,
|
|
90
|
+
dataDir,
|
|
91
|
+
createClient,
|
|
92
|
+
...takeoverOpts,
|
|
93
|
+
});
|
|
94
|
+
console.error(upgradedMessage(r.oldVersion, VERSION, "daemon"));
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
98
|
+
exit(e instanceof WedgedError ? 2 : 1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Unmanaged successor: foreground-start the new daemon and open it.
|
|
102
|
+
const started = await start(o.port ? { port } : undefined);
|
|
103
|
+
await openDashboard(`http://127.0.0.1:${started.port}`, openBrowser);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// 2. Nothing healthy on the target port, but another owner holds the
|
|
107
|
+
// installation: wedged — print recovery, never create a second poller.
|
|
108
|
+
const claim = readClaim(dataDir);
|
|
109
|
+
const fresh = !!claim && Date.now() - new Date(claim.lastBeat).getTime() < 30000;
|
|
110
|
+
if (fresh && claim) {
|
|
111
|
+
const managed = await isManaged().catch(() => false);
|
|
112
|
+
console.error(formatWedged({ claim, port, managed }));
|
|
113
|
+
exit(2);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// 3. Unhealthy but registered: start the login service and open it.
|
|
117
|
+
let attemptedServiceStart = false;
|
|
118
|
+
if (!foregroundOnly) {
|
|
119
|
+
const installed = await isInstalled().catch(() => false);
|
|
120
|
+
if (installed) {
|
|
121
|
+
attemptedServiceStart = true;
|
|
122
|
+
let detail = null;
|
|
123
|
+
try {
|
|
124
|
+
const code = await execService(["start"]);
|
|
125
|
+
if (code !== 0)
|
|
126
|
+
detail = `exit ${code}`;
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
detail = e?.message ?? String(e);
|
|
130
|
+
}
|
|
131
|
+
if (!detail) {
|
|
132
|
+
const healthy = await waitForHealthy(port, VERSION, {
|
|
133
|
+
createClient,
|
|
134
|
+
sleep,
|
|
135
|
+
timeoutMs: readyTimeoutMs,
|
|
136
|
+
});
|
|
137
|
+
if (!healthy)
|
|
138
|
+
detail = `not ready within ${Math.round(readyTimeoutMs / 1000)}s`;
|
|
139
|
+
}
|
|
140
|
+
if (!detail) {
|
|
141
|
+
await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.error(`service start failed (${detail}); starting in the foreground instead`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// 4. Foreground fallback: start, open, and suggest the login service.
|
|
148
|
+
const started = await start(o.port ? { port } : undefined);
|
|
149
|
+
if (!foregroundOnly && !attemptedServiceStart && supported()) {
|
|
150
|
+
console.error("for a background service that survives terminal closes, run 'quotacap service install'");
|
|
151
|
+
}
|
|
152
|
+
await openDashboard(`http://127.0.0.1:${started.port}`, openBrowser);
|
|
153
|
+
}
|
|
9
154
|
async function defaultOpenBrowser(url) {
|
|
10
155
|
const cmd = process.platform === "darwin"
|
|
11
156
|
? "open"
|
|
@@ -37,58 +182,40 @@ export function registerRuntimeCommands(program, deps) {
|
|
|
37
182
|
const exit = deps?.exit ?? process.exit;
|
|
38
183
|
const execService = deps?.execService ??
|
|
39
184
|
((args, opts) => runServiceCommand(args, opts ?? {}, {}));
|
|
185
|
+
const createClient = deps?.createClient ?? ((o) => createServiceClient(o));
|
|
186
|
+
const isManaged = deps?.isManaged ?? isServiceManaged;
|
|
187
|
+
const isInstalled = deps?.isInstalled ?? isServiceInstalled;
|
|
188
|
+
const supported = deps?.serviceSupported ?? serviceSupported;
|
|
189
|
+
const takeoverOpts = deps?.takeoverOpts ?? {};
|
|
190
|
+
const checkUpdates = deps?.checkUpdates ??
|
|
191
|
+
(() => refreshUpdateCache({ channel: detectChannel(), current: VERSION }));
|
|
40
192
|
program
|
|
41
193
|
.command("daemon")
|
|
42
194
|
.option("--foreground", "keep foreground (default: true)")
|
|
43
195
|
.option("--port <n>")
|
|
44
196
|
.description("run the complete service in the foreground")
|
|
45
197
|
.action(async (o) => {
|
|
198
|
+
await ensureConfig();
|
|
46
199
|
const started = await start(o.port ? { port: parseInt(o.port, 10) } : undefined);
|
|
47
200
|
console.log(`quotacap service listening on http://127.0.0.1:${started.port}`);
|
|
48
201
|
});
|
|
49
202
|
program
|
|
50
203
|
.command("web")
|
|
51
204
|
.option("--port <n>")
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
health = null;
|
|
67
|
-
}
|
|
68
|
-
if (health?.ok) {
|
|
69
|
-
if (health.version !== VERSION ||
|
|
70
|
-
(typeof health.exec === "string" &&
|
|
71
|
-
health.exec !== process.execPath)) {
|
|
72
|
-
console.error(`existing service mismatch: ${health.exec ?? "unknown executable"} version ${health.version ?? "unknown"} on port ${port} (this CLI: ${process.execPath} version ${VERSION})`);
|
|
73
|
-
exit(2);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
await openDashboard(`http://127.0.0.1:${port}`, openBrowser);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
// 2. Nothing healthy on the target port, but another owner holds the
|
|
80
|
-
// installation: report, never create a second poller.
|
|
81
|
-
const claim = readClaim(dataDir);
|
|
82
|
-
const fresh = !!claim && Date.now() - new Date(claim.lastBeat).getTime() < 30000;
|
|
83
|
-
if (fresh && claim) {
|
|
84
|
-
console.error(`existing service mismatch: installation owned by ${claim.exec} version ${claim.version} (pid ${claim.pid}, started ${claim.startedAt}); no healthy service on port ${port}`);
|
|
85
|
-
exit(2);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
// 3. Start the foreground service (never login startup) and open it.
|
|
89
|
-
const started = await start(o.port ? { port } : undefined);
|
|
90
|
-
await openDashboard(`http://127.0.0.1:${started.port}`, openBrowser);
|
|
91
|
-
});
|
|
205
|
+
.option("--foreground", "hold the terminal with a foreground service instead of using the login service")
|
|
206
|
+
.description("open the dashboard, starting the service if needed")
|
|
207
|
+
.action(async (o) => launchWeb(o, {
|
|
208
|
+
startService: start,
|
|
209
|
+
openBrowser,
|
|
210
|
+
exit,
|
|
211
|
+
execService,
|
|
212
|
+
createClient,
|
|
213
|
+
isManaged,
|
|
214
|
+
isInstalled,
|
|
215
|
+
serviceSupported: supported,
|
|
216
|
+
takeoverOpts,
|
|
217
|
+
checkUpdates,
|
|
218
|
+
}));
|
|
92
219
|
const service = program
|
|
93
220
|
.command("service")
|
|
94
221
|
.description("manage the per-user login service");
|
|
@@ -6,6 +6,7 @@ export declare class ClientError extends Error {
|
|
|
6
6
|
constructor(kind: ClientErrorKind, message: string);
|
|
7
7
|
}
|
|
8
8
|
export declare const OFFLINE_LABEL = "offline: showing stored readings (service unreachable)";
|
|
9
|
+
export declare function offlineOnlyClient(): ServiceClient;
|
|
9
10
|
export declare function toClientError(e: unknown, action: string): ClientError;
|
|
10
11
|
export declare function openOfflineDb(dbPath: string): any;
|
|
11
12
|
export type SnapshotSource = "service" | "offline";
|
|
@@ -18,6 +18,15 @@ export class ClientError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
// stderr label for labeled offline fallback (stdout stays a pure data contract).
|
|
20
20
|
export const OFFLINE_LABEL = "offline: showing stored readings (service unreachable)";
|
|
21
|
+
// A client that always fails transport: forces resolveSnapshot down the
|
|
22
|
+
// labeled offline path without touching the network. Used when the daemon
|
|
23
|
+
// is reachable but too old for an unmanaged takeover.
|
|
24
|
+
export function offlineOnlyClient() {
|
|
25
|
+
const fail = async () => {
|
|
26
|
+
throw new ServiceUnavailable("service unavailable: skew fallback to stored readings");
|
|
27
|
+
};
|
|
28
|
+
return { get: fail, post: fail };
|
|
29
|
+
}
|
|
21
30
|
const NO_DATA_HINT = "no quotas yet - start the service (quotacap web)";
|
|
22
31
|
function unreadableMessage(dbPath) {
|
|
23
32
|
return `incompatible-schema: ${dbPath} is not a current QuotaCap database - start the service (quotacap web) to upgrade it`;
|
package/dist/cli/status.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { projectQuotasResponse } from "../advisory/snapshot.js";
|
|
2
|
-
import {
|
|
2
|
+
import { ensureConfig, getDbPath } from "../config.js";
|
|
3
3
|
import { renderCompact, renderNarrow, renderWide } from "../format/terminal.js";
|
|
4
4
|
import { assertValidSortKey } from "../format/rows.js";
|
|
5
5
|
import { createServiceClient } from "../runtime/client.js";
|
|
6
|
-
import {
|
|
6
|
+
import { VERSION } from "../version.js";
|
|
7
|
+
import { isServiceManaged, runServiceCommand } from "../service/index.js";
|
|
8
|
+
import { detectChannel, printUpdateFooter, readUpdateCache, refreshUpdateCache, updateFooter, } from "../runtime/updates.js";
|
|
9
|
+
import { ClientError, OFFLINE_LABEL, offlineOnlyClient, resolveSnapshot } from "./snapshot-source.js";
|
|
10
|
+
import { checkSkew, execSkewWarning, olderCliWarning, takeoverManaged, unmanagedSkewWarning, } from "./takeover.js";
|
|
7
11
|
const WIDE_COLUMNS = 100;
|
|
8
12
|
export function registerStatusCommand(program, deps) {
|
|
9
13
|
const createClient = deps.createClient ?? ((o) => createServiceClient(o));
|
|
@@ -11,6 +15,11 @@ export function registerStatusCommand(program, deps) {
|
|
|
11
15
|
const resolveWidth = deps.resolveWidth ?? (() => ({ columns: process.stdout.columns, tty: !!process.stdout.isTTY }));
|
|
12
16
|
const now = deps.now ?? (() => new Date());
|
|
13
17
|
const exit = deps.exit ?? process.exit;
|
|
18
|
+
const execService = deps.execService ?? ((args, o) => runServiceCommand(args, o ?? {}, {}));
|
|
19
|
+
const isManaged = deps.isManaged ?? isServiceManaged;
|
|
20
|
+
const takeoverOpts = deps.takeoverOpts ?? {};
|
|
21
|
+
const checkUpdates = deps.checkUpdates ??
|
|
22
|
+
(() => refreshUpdateCache({ channel: detectChannel(), current: VERSION }));
|
|
14
23
|
program
|
|
15
24
|
.command("status")
|
|
16
25
|
.description("show quota status (service when reachable, stored readings otherwise)")
|
|
@@ -33,8 +42,61 @@ export function registerStatusCommand(program, deps) {
|
|
|
33
42
|
const t = now();
|
|
34
43
|
const compact = !!o.compact;
|
|
35
44
|
const timeoutMs = compact ? 1000 : 2000;
|
|
36
|
-
const cfg = await
|
|
37
|
-
|
|
45
|
+
const { config: cfg } = await ensureConfig();
|
|
46
|
+
let client = createClient({ port: cfg.port, timeoutMs });
|
|
47
|
+
// Skew pre-check: warn on exec-only and older-CLI skew, take the
|
|
48
|
+
// managed path when newer, and never spawn a foreground daemon here —
|
|
49
|
+
// unmanaged skew serves the stored snapshot instead of failing.
|
|
50
|
+
let health = null;
|
|
51
|
+
try {
|
|
52
|
+
health = await client.get("/health");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
health = null;
|
|
56
|
+
}
|
|
57
|
+
if (health?.ok) {
|
|
58
|
+
const skew = checkSkew(health, VERSION, process.execPath);
|
|
59
|
+
if (skew === "exec-only") {
|
|
60
|
+
console.error(execSkewWarning(String(health.exec)));
|
|
61
|
+
}
|
|
62
|
+
else if (skew === "cli-older") {
|
|
63
|
+
console.error(olderCliWarning(String(health.version ?? "unknown")));
|
|
64
|
+
}
|
|
65
|
+
else if (skew === "cli-newer") {
|
|
66
|
+
const managed = await isManaged().catch(() => false);
|
|
67
|
+
if (managed) {
|
|
68
|
+
try {
|
|
69
|
+
const r = await takeoverManaged({
|
|
70
|
+
port: cfg.port,
|
|
71
|
+
health,
|
|
72
|
+
createClient,
|
|
73
|
+
execService,
|
|
74
|
+
...takeoverOpts,
|
|
75
|
+
});
|
|
76
|
+
console.error(r.message);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
80
|
+
exit(1);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
console.error(unmanagedSkewWarning(String(health.version ?? "unknown")));
|
|
86
|
+
client = offlineOnlyClient();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Passive daily update signal: opportunistically refresh the shared
|
|
91
|
+
// cache, then surface staleness as a stderr footer (never in --json).
|
|
92
|
+
let updateCache = null;
|
|
93
|
+
try {
|
|
94
|
+
updateCache = await checkUpdates();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
updateCache = readUpdateCache();
|
|
98
|
+
}
|
|
99
|
+
const footer = updateFooter(VERSION, updateCache?.latest ?? null);
|
|
38
100
|
let resolved;
|
|
39
101
|
try {
|
|
40
102
|
resolved = await resolveSnapshot({
|
|
@@ -53,9 +115,11 @@ export function registerStatusCommand(program, deps) {
|
|
|
53
115
|
}
|
|
54
116
|
if (compact) {
|
|
55
117
|
console.log("(use: none)");
|
|
118
|
+
printUpdateFooter(o, footer);
|
|
56
119
|
return;
|
|
57
120
|
}
|
|
58
121
|
console.log(e.message);
|
|
122
|
+
printUpdateFooter(o, footer);
|
|
59
123
|
return;
|
|
60
124
|
}
|
|
61
125
|
console.error(e instanceof Error ? e.message : String(e));
|
|
@@ -71,6 +135,7 @@ export function registerStatusCommand(program, deps) {
|
|
|
71
135
|
}
|
|
72
136
|
if (compact) {
|
|
73
137
|
console.log(renderCompact(snapshot, t));
|
|
138
|
+
printUpdateFooter(o, footer);
|
|
74
139
|
return;
|
|
75
140
|
}
|
|
76
141
|
const { columns, tty } = resolveWidth();
|
|
@@ -80,5 +145,6 @@ export function registerStatusCommand(program, deps) {
|
|
|
80
145
|
console.log(wide
|
|
81
146
|
? renderWide(snapshot, { now: t, ascii, color, sort })
|
|
82
147
|
: renderNarrow(snapshot, { now: t, ascii, color, sort }));
|
|
148
|
+
printUpdateFooter(o, footer);
|
|
83
149
|
});
|
|
84
150
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ServiceClient } from "../runtime/client.js";
|
|
2
|
+
import { type ClaimInfo } from "../runtime/owner.js";
|
|
3
|
+
import { isServiceManaged } from "../service/index.js";
|
|
4
|
+
export type SkewKind = "match" | "exec-only" | "cli-newer" | "cli-older";
|
|
5
|
+
export interface HealthView {
|
|
6
|
+
ok?: unknown;
|
|
7
|
+
version?: unknown;
|
|
8
|
+
exec?: unknown;
|
|
9
|
+
}
|
|
10
|
+
export { parseVersion, compareVersions } from "../runtime/versions.js";
|
|
11
|
+
export declare function checkSkew(health: HealthView, cliVersion?: string, cliExec?: string): SkewKind;
|
|
12
|
+
/** Upgrade attempted but failed (restart error, readiness timeout, port never released). Callers exit 1. */
|
|
13
|
+
export declare class TakeoverError extends Error {
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
/** Daemon cannot be asked to stop (old binary, missing token). Callers print the message and exit 2. */
|
|
17
|
+
export declare class WedgedError extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
export declare function olderCliWarning(daemonVersion: string): string;
|
|
21
|
+
export declare function execSkewWarning(daemonExec: string, cliVersion?: string, cliExec?: string): string;
|
|
22
|
+
export declare function unmanagedSkewWarning(daemonVersion: string): string;
|
|
23
|
+
export declare function upgradedMessage(oldVersion: string, cliVersion: string, via: "service" | "daemon"): string;
|
|
24
|
+
export declare function formatWedged(opts: {
|
|
25
|
+
claim: ClaimInfo | null;
|
|
26
|
+
port: number;
|
|
27
|
+
managed: boolean;
|
|
28
|
+
}): string;
|
|
29
|
+
export type SleepFn = (ms: number) => Promise<void>;
|
|
30
|
+
export type CreateTakeoverClient = (opts: {
|
|
31
|
+
port: number;
|
|
32
|
+
token?: string;
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
}) => ServiceClient;
|
|
35
|
+
export type ExecServiceFn = (args: string[], opts?: Record<string, any>) => Promise<number>;
|
|
36
|
+
export interface WaitOpts {
|
|
37
|
+
createClient?: CreateTakeoverClient;
|
|
38
|
+
sleep?: SleepFn;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function waitForHealthy(port: number, expectedVersion: string, opts?: WaitOpts): Promise<any | null>;
|
|
42
|
+
export declare function waitForRelease(port: number, dataDir?: string, opts?: WaitOpts): Promise<boolean>;
|
|
43
|
+
export interface TakeoverSharedOpts extends WaitOpts {
|
|
44
|
+
port: number;
|
|
45
|
+
cliVersion?: string;
|
|
46
|
+
cliExec?: string;
|
|
47
|
+
dataDir?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface ManagedTakeoverOpts extends TakeoverSharedOpts {
|
|
50
|
+
health: HealthView;
|
|
51
|
+
execService?: ExecServiceFn;
|
|
52
|
+
}
|
|
53
|
+
export declare function takeoverManaged(opts: ManagedTakeoverOpts): Promise<{
|
|
54
|
+
oldVersion: string;
|
|
55
|
+
message: string;
|
|
56
|
+
}>;
|
|
57
|
+
export interface UnmanagedTakeoverOpts extends TakeoverSharedOpts {
|
|
58
|
+
health: HealthView;
|
|
59
|
+
readToken?: () => string | undefined;
|
|
60
|
+
}
|
|
61
|
+
export declare function takeoverUnmanaged(opts: UnmanagedTakeoverOpts): Promise<{
|
|
62
|
+
oldVersion: string;
|
|
63
|
+
}>;
|
|
64
|
+
export { isServiceManaged };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Daemon skew detection and takeover protocol (design section 6). Shared by
|
|
2
|
+
// `web`, `status`, `advise`, and (Task 3) `update`: every command that
|
|
3
|
+
// contacts /health compares version and exec, and a CLI newer than the
|
|
4
|
+
// daemon restarts it instead of failing with exit code 2.
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { VERSION } from "../version.js";
|
|
7
|
+
import { getDbPath } from "../config.js";
|
|
8
|
+
import { createServiceClient, ServiceError, ServiceUnavailable, } from "../runtime/client.js";
|
|
9
|
+
import { readToken } from "../runtime/token.js";
|
|
10
|
+
import { readClaim } from "../runtime/owner.js";
|
|
11
|
+
import { runServiceCommand, isServiceManaged } from "../service/index.js";
|
|
12
|
+
export { parseVersion, compareVersions } from "../runtime/versions.js";
|
|
13
|
+
import { compareVersions, parseVersion } from "../runtime/versions.js";
|
|
14
|
+
export function checkSkew(health, cliVersion = VERSION, cliExec = process.execPath) {
|
|
15
|
+
const hv = typeof health.version === "string" ? health.version : "";
|
|
16
|
+
const hex = typeof health.exec === "string" ? health.exec : "";
|
|
17
|
+
if (compareVersions(cliVersion, hv) === 0) {
|
|
18
|
+
return !hex || hex === cliExec ? "match" : "exec-only";
|
|
19
|
+
}
|
|
20
|
+
// Unequal versions: the CLI-newer path runs only when the CLI version
|
|
21
|
+
// parses and the daemon version does not sort above it. Anything else
|
|
22
|
+
// warns and proceeds; a newer daemon is never restarted into an older CLI.
|
|
23
|
+
if (parseVersion(cliVersion) && compareVersions(hv, cliVersion) <= 0) {
|
|
24
|
+
return "cli-newer";
|
|
25
|
+
}
|
|
26
|
+
return "cli-older";
|
|
27
|
+
}
|
|
28
|
+
/** Upgrade attempted but failed (restart error, readiness timeout, port never released). Callers exit 1. */
|
|
29
|
+
export class TakeoverError extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "TakeoverError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Daemon cannot be asked to stop (old binary, missing token). Callers print the message and exit 2. */
|
|
36
|
+
export class WedgedError extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "WedgedError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function olderCliWarning(daemonVersion) {
|
|
43
|
+
return `daemon is newer (${daemonVersion}); run 'quotacap update' to match it`;
|
|
44
|
+
}
|
|
45
|
+
export function execSkewWarning(daemonExec, cliVersion = VERSION, cliExec = process.execPath) {
|
|
46
|
+
return `warning: service executable (${daemonExec}) differs from this CLI (${cliExec}) but versions match (${cliVersion}); continuing`;
|
|
47
|
+
}
|
|
48
|
+
export function unmanagedSkewWarning(daemonVersion) {
|
|
49
|
+
return `warning: daemon is older (${daemonVersion}) and no background service is registered; showing stored readings — run 'quotacap web' to upgrade`;
|
|
50
|
+
}
|
|
51
|
+
export function upgradedMessage(oldVersion, cliVersion, via) {
|
|
52
|
+
return `Upgraded daemon from ${oldVersion} to ${cliVersion} (${via === "service" ? "service restarted" : "daemon restarted"})`;
|
|
53
|
+
}
|
|
54
|
+
// Recovery instructions for a daemon that cannot be reached. The tool never
|
|
55
|
+
// kills by PID; the human verifies first. This is the only surviving use of
|
|
56
|
+
// the mismatch exit code.
|
|
57
|
+
export function formatWedged(opts) {
|
|
58
|
+
const { claim, port, managed } = opts;
|
|
59
|
+
if (managed) {
|
|
60
|
+
return (`existing service mismatch: daemon on port ${port} is not answering but a background service is registered; ` +
|
|
61
|
+
`run 'quotacap service restart' (or 'quotacap service stop' and retry) to recover`);
|
|
62
|
+
}
|
|
63
|
+
if (claim) {
|
|
64
|
+
return (`existing service mismatch: installation owned by ${claim.exec} version ${claim.version} ` +
|
|
65
|
+
`(pid ${claim.pid}, started ${claim.startedAt}) is not answering on port ${port}; ` +
|
|
66
|
+
`stop that process in its own terminal, or verify it with 'ps -p ${claim.pid}' and then 'kill ${claim.pid}' before retrying`);
|
|
67
|
+
}
|
|
68
|
+
return (`existing service mismatch: daemon on port ${port} is not answering and no ownership claim was found; ` +
|
|
69
|
+
`stop the process listening on port ${port} in its own terminal and retry`);
|
|
70
|
+
}
|
|
71
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
72
|
+
// Poll /health until it reports the expected version (or the deadline).
|
|
73
|
+
export async function waitForHealthy(port, expectedVersion, opts = {}) {
|
|
74
|
+
const createClient = opts.createClient ?? createServiceClient;
|
|
75
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
76
|
+
const timeoutMs = opts.timeoutMs ?? 15000;
|
|
77
|
+
const deadline = Date.now() + timeoutMs;
|
|
78
|
+
const client = createClient({ port, timeoutMs: 1000 });
|
|
79
|
+
for (;;) {
|
|
80
|
+
try {
|
|
81
|
+
const h = await client.get("/health");
|
|
82
|
+
if (h?.ok && h?.ready && h?.version === expectedVersion)
|
|
83
|
+
return h;
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
if (Date.now() >= deadline)
|
|
87
|
+
return null;
|
|
88
|
+
await sleep(500);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Poll until the port stops answering AND the ownership claim is gone.
|
|
92
|
+
export async function waitForRelease(port, dataDir = path.dirname(getDbPath()), opts = {}) {
|
|
93
|
+
const createClient = opts.createClient ?? createServiceClient;
|
|
94
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
95
|
+
const timeoutMs = opts.timeoutMs ?? 15000;
|
|
96
|
+
const deadline = Date.now() + timeoutMs;
|
|
97
|
+
const client = createClient({ port, timeoutMs: 1000 });
|
|
98
|
+
for (;;) {
|
|
99
|
+
let portDown = false;
|
|
100
|
+
try {
|
|
101
|
+
await client.get("/health");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
portDown = true;
|
|
105
|
+
}
|
|
106
|
+
if (portDown && readClaim(dataDir) === null)
|
|
107
|
+
return true;
|
|
108
|
+
if (Date.now() >= deadline)
|
|
109
|
+
return false;
|
|
110
|
+
await sleep(250);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Managed path: restart the supervisor-owned job, wait for readiness, and
|
|
114
|
+
// verify /health reports the CLI version before declaring success.
|
|
115
|
+
export async function takeoverManaged(opts) {
|
|
116
|
+
const cliVersion = opts.cliVersion ?? VERSION;
|
|
117
|
+
const oldVersion = typeof opts.health.version === "string" && opts.health.version
|
|
118
|
+
? opts.health.version
|
|
119
|
+
: "unknown";
|
|
120
|
+
const execService = opts.execService ?? ((args, o) => runServiceCommand(args, o ?? {}, {}));
|
|
121
|
+
const code = await execService(["restart"]);
|
|
122
|
+
if (code !== 0) {
|
|
123
|
+
throw new TakeoverError(`daemon upgrade failed: service restart exited ${code}`);
|
|
124
|
+
}
|
|
125
|
+
const healthy = await waitForHealthy(opts.port, cliVersion, opts);
|
|
126
|
+
if (!healthy) {
|
|
127
|
+
throw new TakeoverError(`daemon upgrade failed: service did not report version ${cliVersion} within 15s; run 'quotacap service status' to inspect`);
|
|
128
|
+
}
|
|
129
|
+
return { oldVersion, message: upgradedMessage(oldVersion, cliVersion, "service") };
|
|
130
|
+
}
|
|
131
|
+
// Unmanaged path: ask the reachable daemon to stop gracefully, then wait for
|
|
132
|
+
// the port and claim to release. The caller spawns the successor (or, for
|
|
133
|
+
// `update`, leaves it stopped with guidance).
|
|
134
|
+
export async function takeoverUnmanaged(opts) {
|
|
135
|
+
const port = opts.port;
|
|
136
|
+
const cliVersion = opts.cliVersion ?? VERSION;
|
|
137
|
+
const cliExec = opts.cliExec ?? process.execPath;
|
|
138
|
+
const dataDir = opts.dataDir ?? path.dirname(getDbPath());
|
|
139
|
+
const oldVersion = typeof opts.health.version === "string" && opts.health.version
|
|
140
|
+
? opts.health.version
|
|
141
|
+
: "unknown";
|
|
142
|
+
const token = (opts.readToken ?? readToken)();
|
|
143
|
+
if (!token) {
|
|
144
|
+
throw new WedgedError(formatWedged({ claim: readClaim(dataDir), port, managed: false }));
|
|
145
|
+
}
|
|
146
|
+
const createClient = opts.createClient ?? createServiceClient;
|
|
147
|
+
const client = createClient({ port, token, timeoutMs: 5000 });
|
|
148
|
+
try {
|
|
149
|
+
// Always send the info body: Fastify rejects content-type json with an
|
|
150
|
+
// empty body, and the daemon logs the requester.
|
|
151
|
+
await client.post("/api/restart", { version: cliVersion, exec: cliExec });
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
if (e instanceof ServiceError && e.status === 404) {
|
|
155
|
+
// Old daemon predates /api/restart and cannot be asked to stop.
|
|
156
|
+
throw new WedgedError(formatWedged({ claim: readClaim(dataDir), port, managed: false }));
|
|
157
|
+
}
|
|
158
|
+
if (e instanceof ServiceError && e.status === 401) {
|
|
159
|
+
throw new TakeoverError(`daemon upgrade failed: restart rejected (401) on port ${port}`);
|
|
160
|
+
}
|
|
161
|
+
if (e instanceof ServiceError) {
|
|
162
|
+
throw new TakeoverError(`daemon upgrade failed: restart request failed (HTTP ${e.status}) on port ${port}`);
|
|
163
|
+
}
|
|
164
|
+
if (!(e instanceof ServiceUnavailable))
|
|
165
|
+
throw e;
|
|
166
|
+
// Connection failed: the daemon may already be stopping; fall through
|
|
167
|
+
// to the release wait, which succeeds fast when it is gone.
|
|
168
|
+
}
|
|
169
|
+
const released = await waitForRelease(port, dataDir, opts);
|
|
170
|
+
if (!released) {
|
|
171
|
+
throw new TakeoverError(`daemon upgrade failed: old daemon did not stop within 15s; stop the process on port ${port} in its own terminal and retry`);
|
|
172
|
+
}
|
|
173
|
+
return { oldVersion };
|
|
174
|
+
}
|
|
175
|
+
export { isServiceManaged };
|