quotacap 0.0.24 → 0.0.25
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 +2 -2
- package/dist/cli/takeover.d.ts +1 -0
- package/dist/cli/takeover.js +3 -2
- package/dist/cli/update.js +5 -2
- package/dist/config.d.ts +25 -0
- package/dist/config.js +102 -0
- package/dist/runtime/service.js +11 -4
- package/dist/service/index.js +4 -1
- package/dist/service/macos.d.ts +11 -0
- package/dist/service/macos.js +34 -3
- package/dist/service/systemd.d.ts +2 -0
- package/dist/service/systemd.js +12 -4
- package/dist/version.js +1 -1
- package/dist/web/src/components/ResetRail.js +1 -1
- package/dist/webAssets.js +1 -1
- package/dist/webHtml.js +1 -1
- package/package.json +1 -1
- package/web/dist/assets/{index-CzObzbxb.js → index-ClNhsr1-.js} +1 -1
- package/web/dist/assets/index-TJk_KDbS.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-VioQmN7J.css +0 -1
package/README.md
CHANGED
|
@@ -117,11 +117,11 @@ curl -X PATCH http://localhost:8787/api/providers/claude \
|
|
|
117
117
|
|
|
118
118
|
## Security
|
|
119
119
|
|
|
120
|
-
QuotaCap is a local daemon. It binds to `127.0.0.1` only (`src/
|
|
120
|
+
QuotaCap is a local daemon. It binds to `127.0.0.1` only (`src/runtime/service.ts` `app.listen`). It does not listen on `0.0.0.0`. There is no LAN surface.
|
|
121
121
|
|
|
122
122
|
It owns no tokens. It never reads `~/.codex/auth.json`, `~/.kimi-code/credentials/kimi-code.json`, `~/.kimi/credentials/kimi-code.json`, `~/.grok/auth.json`, or `~/.gemini/oauth_creds.json`. It never uses `refresh_token` or `grant_type=refresh_token`. It has no hardcoded client ids. Those OAuth paths and the `.qc-bak` and `.qc-lock` helpers were removed in #14. This is asserted by `tests/adapters/credential-free.test.ts`. Each CLI owns its own session. It never reads `~/.config/muse/auth.json`, `~/.local/share/muse/sessions/`, or `~/.config/muse/tui-history.jsonl`. Each CLI owns its own session. QuotaCap only spawns the CLI and reads its stdout via `exec` (`claude`, `agy`) or PTY (`codex`, `kimi`, `grok`, `muse`).
|
|
123
123
|
|
|
124
|
-
It stores no `raw` provider payload. The `raw` column was dropped and migrated in `src/store/db.ts
|
|
124
|
+
It stores no `raw` provider payload. The `raw` column was dropped and migrated in `src/store/db.ts` `migrate`. `GET /api/quotas` and MCP `get_quotas` never return `raw` (`tests/http/api.test.ts`). History and the token live under `~/.quotacap/` with `0700` on the directory and `0600` on files.
|
|
125
125
|
|
|
126
126
|
Every request checks `Host` and `Origin`. `Host` must be loopback (`127.0.0.1`, `localhost`, `[::1]`), otherwise `403`. `Origin` when present must be loopback, otherwise `403`. Absent `Origin` passes for `curl` and MCP. `POST /api/refresh` requires `X-QuotaCap-Token` matching `~/.quotacap/token` with `crypto.timingSafeEqual` (`src/http/server.ts:isValidToken`), otherwise `401`. `GET /assets/*` is rooted with `path.resolve` and a prefix check (`tests/http/api.test.ts`).
|
|
127
127
|
|
package/dist/cli/takeover.d.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface TakeoverSharedOpts extends WaitOpts {
|
|
|
49
49
|
export interface ManagedTakeoverOpts extends TakeoverSharedOpts {
|
|
50
50
|
health: HealthView;
|
|
51
51
|
execService?: ExecServiceFn;
|
|
52
|
+
refreshRegistration?: boolean;
|
|
52
53
|
}
|
|
53
54
|
export declare function takeoverManaged(opts: ManagedTakeoverOpts): Promise<{
|
|
54
55
|
oldVersion: string;
|
package/dist/cli/takeover.js
CHANGED
|
@@ -118,9 +118,10 @@ export async function takeoverManaged(opts) {
|
|
|
118
118
|
? opts.health.version
|
|
119
119
|
: "unknown";
|
|
120
120
|
const execService = opts.execService ?? ((args, o) => runServiceCommand(args, o ?? {}, {}));
|
|
121
|
-
const
|
|
121
|
+
const verb = opts.refreshRegistration ? "install" : "restart";
|
|
122
|
+
const code = await execService([verb], opts.refreshRegistration ? { version: cliVersion } : undefined);
|
|
122
123
|
if (code !== 0) {
|
|
123
|
-
throw new TakeoverError(`daemon upgrade failed: service
|
|
124
|
+
throw new TakeoverError(`daemon upgrade failed: service ${verb} exited ${code}`);
|
|
124
125
|
}
|
|
125
126
|
const healthy = await waitForHealthy(opts.port, cliVersion, opts);
|
|
126
127
|
if (!healthy) {
|
package/dist/cli/update.js
CHANGED
|
@@ -36,8 +36,10 @@ function unknownRefusal() {
|
|
|
36
36
|
}
|
|
37
37
|
// Restart the running daemon onto the just-installed target version. Unlike
|
|
38
38
|
// the skew paths in web/status/advise, the comparison version is the target
|
|
39
|
-
// (the running CLI is still the old binary),
|
|
40
|
-
//
|
|
39
|
+
// (the running CLI is still the old binary), the managed path refreshes the
|
|
40
|
+
// service registration instead of only restarting (regenerating the
|
|
41
|
+
// supervisor PATH), and the unmanaged path leaves the daemon stopped with
|
|
42
|
+
// guidance instead of foreground-starting.
|
|
41
43
|
export async function postUpdateTakeover(opts) {
|
|
42
44
|
const { target, port } = opts;
|
|
43
45
|
const dataDir = opts.dataDir ?? path.dirname(getDbPath());
|
|
@@ -87,6 +89,7 @@ export async function postUpdateTakeover(opts) {
|
|
|
87
89
|
cliExec: execPath,
|
|
88
90
|
createClient,
|
|
89
91
|
execService,
|
|
92
|
+
refreshRegistration: true,
|
|
90
93
|
...takeoverOpts,
|
|
91
94
|
});
|
|
92
95
|
return { notes: [r.message], warnings: [], error: null, exitCode: 0 };
|
package/dist/config.d.ts
CHANGED
|
@@ -6,18 +6,21 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
6
6
|
port: z.ZodDefault<z.ZodNumber>;
|
|
7
7
|
pollMinutes: z.ZodDefault<z.ZodNumber>;
|
|
8
8
|
enabledProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
9
|
+
knownProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
9
10
|
providerNames: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>>;
|
|
10
11
|
experimentalIngest: z.ZodOptional<z.ZodBoolean>;
|
|
11
12
|
}, "strip", z.ZodTypeAny, {
|
|
12
13
|
port: number;
|
|
13
14
|
pollMinutes: number;
|
|
14
15
|
enabledProviders: string[];
|
|
16
|
+
knownProviders: string[];
|
|
15
17
|
providerNames: Record<string, string>;
|
|
16
18
|
experimentalIngest?: boolean | undefined;
|
|
17
19
|
}, {
|
|
18
20
|
port?: number | undefined;
|
|
19
21
|
pollMinutes?: number | undefined;
|
|
20
22
|
enabledProviders?: string[] | undefined;
|
|
23
|
+
knownProviders?: string[] | undefined;
|
|
21
24
|
providerNames?: Record<string, string> | undefined;
|
|
22
25
|
experimentalIngest?: boolean | undefined;
|
|
23
26
|
}>;
|
|
@@ -45,6 +48,28 @@ export declare function writeConfig(c: Config, p?: string): Promise<void>;
|
|
|
45
48
|
*/
|
|
46
49
|
export declare function setProviderNameOverride(id: string, displayName: string | null, p?: string): Promise<void>;
|
|
47
50
|
export declare function resetAllProviderNameOverrides(p?: string): Promise<void>;
|
|
51
|
+
export declare const LEGACY_KNOWN_PROVIDERS: string[];
|
|
52
|
+
export interface AutoEnableDeps {
|
|
53
|
+
configPath?: string;
|
|
54
|
+
which?: (bin: string) => string | null;
|
|
55
|
+
}
|
|
56
|
+
export interface AutoEnableResult {
|
|
57
|
+
enabledProviders: string[];
|
|
58
|
+
knownProviders: string[];
|
|
59
|
+
changed: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Auto-enable newly shipped adapters. For each registered adapter (minus
|
|
63
|
+
* `manual`, which has no CLI binary) absent from knownProviders, resolve its
|
|
64
|
+
* binary on PATH and append it to both lists when found. An adapter whose
|
|
65
|
+
* binary is missing stays unknown so it is re-checked on the next start; a
|
|
66
|
+
* provider already known but disabled is never re-added. Raw-JSON mutation
|
|
67
|
+
* like setProviderNameOverride: unknown keys are preserved, never a schema
|
|
68
|
+
* round-trip. Returns null when the file is missing, unparseable, or
|
|
69
|
+
* structurally off, so readServiceConfig keeps owning that error; throws
|
|
70
|
+
* only when a decided write fails. Persists only when something changed.
|
|
71
|
+
*/
|
|
72
|
+
export declare function autoEnableNewProviders(deps?: AutoEnableDeps): Promise<AutoEnableResult | null>;
|
|
48
73
|
export interface ServiceMetadata {
|
|
49
74
|
version: string;
|
|
50
75
|
exec: string;
|
package/dist/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import fsSync from "node:fs";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { z } from "zod";
|
|
@@ -33,6 +34,7 @@ const ConfigSchema = z.object({
|
|
|
33
34
|
port: z.number().default(8787),
|
|
34
35
|
pollMinutes: z.number().default(15),
|
|
35
36
|
enabledProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
|
|
37
|
+
knownProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
|
|
36
38
|
providerNames: z.record(z.string(), ProviderDisplayNameSchema).default({}),
|
|
37
39
|
// Optional, omitted from defaults and `init` output. Manual ingest stays
|
|
38
40
|
// in-tree but is not a public surface until the product design lands.
|
|
@@ -94,6 +96,10 @@ const ServiceConfigSchema = z.object({
|
|
|
94
96
|
});
|
|
95
97
|
}
|
|
96
98
|
}),
|
|
99
|
+
// Machine-managed by autoEnableNewProviders: ids are deliberately NOT
|
|
100
|
+
// validated against the registry, so a stale entry can never brick daemon
|
|
101
|
+
// start. A non-array still fails naming the field.
|
|
102
|
+
knownProviders: z.array(z.string()).default(["claude", "codex", "kimi", "grok", "agy", "muse"]),
|
|
97
103
|
providerNames: z.record(z.string(), ProviderDisplayNameSchema).default({}),
|
|
98
104
|
experimentalIngest: z.boolean().optional(),
|
|
99
105
|
});
|
|
@@ -220,6 +226,102 @@ export async function resetAllProviderNameOverrides(p) {
|
|
|
220
226
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
221
227
|
await fs.writeFile(file, JSON.stringify(rawObj, null, 2) + "\n");
|
|
222
228
|
}
|
|
229
|
+
// Frozen pre-auto-enable provider set. Configs written before knownProviders
|
|
230
|
+
// existed are seeded with exactly these five, so any adapter shipped later
|
|
231
|
+
// reads as new. Never extend this list: seeding from the live registry would
|
|
232
|
+
// mark every adapter known and silently disable auto-enable.
|
|
233
|
+
export const LEGACY_KNOWN_PROVIDERS = ["claude", "codex", "kimi", "grok", "agy"];
|
|
234
|
+
// PATH lookup kept local: importing the sibling in src/service/macos.ts
|
|
235
|
+
// would create a config<->service import cycle.
|
|
236
|
+
function defaultWhich(bin) {
|
|
237
|
+
try {
|
|
238
|
+
const out = execFileSync("which", [bin], {
|
|
239
|
+
encoding: "utf8",
|
|
240
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
241
|
+
}).trim();
|
|
242
|
+
return out.length > 0 ? out : null;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Auto-enable newly shipped adapters. For each registered adapter (minus
|
|
250
|
+
* `manual`, which has no CLI binary) absent from knownProviders, resolve its
|
|
251
|
+
* binary on PATH and append it to both lists when found. An adapter whose
|
|
252
|
+
* binary is missing stays unknown so it is re-checked on the next start; a
|
|
253
|
+
* provider already known but disabled is never re-added. Raw-JSON mutation
|
|
254
|
+
* like setProviderNameOverride: unknown keys are preserved, never a schema
|
|
255
|
+
* round-trip. Returns null when the file is missing, unparseable, or
|
|
256
|
+
* structurally off, so readServiceConfig keeps owning that error; throws
|
|
257
|
+
* only when a decided write fails. Persists only when something changed.
|
|
258
|
+
*/
|
|
259
|
+
export async function autoEnableNewProviders(deps) {
|
|
260
|
+
const file = getConfigPath(deps?.configPath);
|
|
261
|
+
let rawObj;
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
264
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
rawObj = parsed;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
if (("enabledProviders" in rawObj && !Array.isArray(rawObj.enabledProviders)) ||
|
|
273
|
+
("knownProviders" in rawObj && !Array.isArray(rawObj.knownProviders))) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const which = deps?.which ?? defaultWhich;
|
|
277
|
+
const prevEnabled = Array.isArray(rawObj.enabledProviders)
|
|
278
|
+
? rawObj.enabledProviders
|
|
279
|
+
: defaultConfig().enabledProviders;
|
|
280
|
+
const prevKnown = Array.isArray(rawObj.knownProviders)
|
|
281
|
+
? rawObj.knownProviders
|
|
282
|
+
: LEGACY_KNOWN_PROVIDERS;
|
|
283
|
+
const enabled = [...prevEnabled];
|
|
284
|
+
const known = [...prevKnown];
|
|
285
|
+
for (const id of Object.keys(adapters)) {
|
|
286
|
+
if (id === "manual" || known.includes(id))
|
|
287
|
+
continue;
|
|
288
|
+
let resolved = null;
|
|
289
|
+
try {
|
|
290
|
+
resolved = which(id);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
resolved = null;
|
|
294
|
+
}
|
|
295
|
+
if (!resolved)
|
|
296
|
+
continue;
|
|
297
|
+
if (!enabled.includes(id))
|
|
298
|
+
enabled.push(id);
|
|
299
|
+
known.push(id);
|
|
300
|
+
}
|
|
301
|
+
const changed = JSON.stringify(enabled) !== JSON.stringify(prevEnabled) ||
|
|
302
|
+
JSON.stringify(known) !== JSON.stringify(prevKnown);
|
|
303
|
+
if (!changed) {
|
|
304
|
+
return {
|
|
305
|
+
enabledProviders: enabled,
|
|
306
|
+
knownProviders: known,
|
|
307
|
+
changed: false,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
rawObj.enabledProviders = enabled;
|
|
311
|
+
rawObj.knownProviders = known;
|
|
312
|
+
try {
|
|
313
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
314
|
+
await fs.writeFile(file, JSON.stringify(rawObj, null, 2) + "\n");
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
throw new Error(`cannot update provider enablement: failed to write ${file}: ${err?.message ?? err}`);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
enabledProviders: enabled,
|
|
321
|
+
knownProviders: known,
|
|
322
|
+
changed: true,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
223
325
|
export function serviceMetadataPath(dataDir = path.dirname(getDbPath())) {
|
|
224
326
|
return path.join(dataDir, "service.json");
|
|
225
327
|
}
|
package/dist/runtime/service.js
CHANGED
|
@@ -11,7 +11,7 @@ import { createCoordinator } from "./poll.js";
|
|
|
11
11
|
import { killAll } from "./spawn.js";
|
|
12
12
|
import { ensureToken } from "./token.js";
|
|
13
13
|
import { openDb, migrate } from "../store/db.js";
|
|
14
|
-
import { ensureConfig, getConfigPath, getDbPath, isExperimentalIngestEnabled, readServiceConfig, readServiceMetadata } from "../config.js";
|
|
14
|
+
import { autoEnableNewProviders, ensureConfig, getConfigPath, getDbPath, isExperimentalIngestEnabled, readServiceConfig, readServiceMetadata } from "../config.js";
|
|
15
15
|
import { buildApp } from "../http/server.js";
|
|
16
16
|
import { detectChannel, refreshUpdateCache } from "./updates.js";
|
|
17
17
|
import { VERSION } from "../version.js";
|
|
@@ -115,10 +115,17 @@ export async function startService(opts) {
|
|
|
115
115
|
exit(1);
|
|
116
116
|
throw err instanceof Error ? err : new Error(String(err));
|
|
117
117
|
};
|
|
118
|
-
// 1. Provision config when absent,
|
|
119
|
-
// invalid file still fails naming
|
|
120
|
-
// real file behind.
|
|
118
|
+
// 1. Provision config when absent, auto-enable newly shipped adapters,
|
|
119
|
+
// then strict validation (a present-but-invalid file still fails naming
|
|
120
|
+
// the field). The daemon always leaves a real file behind. Auto-enable
|
|
121
|
+
// runs before validation on every start so all upgrade channels (npm,
|
|
122
|
+
// brew, installer, manual binary swap) pick up new adapters; a failed
|
|
123
|
+
// migration warns and continues with the unmigrated file instead of
|
|
124
|
+
// refusing to serve.
|
|
121
125
|
await ensureConfig().catch((e) => fail(e));
|
|
126
|
+
await autoEnableNewProviders().catch((e) => {
|
|
127
|
+
console.warn(`[quotacap] provider auto-enable skipped: ${e instanceof Error ? e.message : String(e)}`);
|
|
128
|
+
});
|
|
122
129
|
const config = await readServiceConfig().catch((e) => fail(e));
|
|
123
130
|
const port = opts?.port ?? config.port;
|
|
124
131
|
const dataDir = opts?.dataDir ?? path.dirname(getDbPath());
|
package/dist/service/index.js
CHANGED
|
@@ -73,7 +73,10 @@ export async function runServiceCommand(args, opts = {}, deps = {}) {
|
|
|
73
73
|
try {
|
|
74
74
|
switch (verb) {
|
|
75
75
|
case "install":
|
|
76
|
-
|
|
76
|
+
// Internal: the post-update refresh passes the target version so
|
|
77
|
+
// install compares and records it instead of the old in-process
|
|
78
|
+
// VERSION. The `service install` CLI never sets opts.version.
|
|
79
|
+
await backend.install({ ...deps, version: opts.version ?? deps.version });
|
|
77
80
|
break;
|
|
78
81
|
case "uninstall":
|
|
79
82
|
await backend.uninstall(deps);
|
package/dist/service/macos.d.ts
CHANGED
|
@@ -14,17 +14,28 @@ export interface ServiceDeps {
|
|
|
14
14
|
which?: (bin: string) => string | null;
|
|
15
15
|
lintPlist?: (plistFile: string) => void;
|
|
16
16
|
waitReady?: (port: number) => Promise<boolean>;
|
|
17
|
+
waitReleased?: (port: number) => Promise<boolean>;
|
|
17
18
|
print?: (msg: string) => void;
|
|
18
19
|
error?: (msg: string) => void;
|
|
19
20
|
now?: () => Date;
|
|
20
21
|
sleep?: (ms: number) => Promise<void>;
|
|
21
22
|
bootstrapTimeoutMs?: number;
|
|
23
|
+
version?: string;
|
|
22
24
|
}
|
|
23
25
|
export declare function foregroundGuidance(verb: string, platform: string): string;
|
|
24
26
|
export declare function defaultRunLaunchctl(args: string[]): string;
|
|
25
27
|
export declare function defaultWhich(bin: string): string | null;
|
|
26
28
|
export declare function defaultLintPlist(plistFile: string): void;
|
|
27
29
|
export declare function defaultWaitReady(port: number, timeoutMs?: number): Promise<boolean>;
|
|
30
|
+
/**
|
|
31
|
+
* `launchctl bootout` and `systemctl --user stop` both return before the job
|
|
32
|
+
* has actually gone, so a restart that starts immediately races the dying
|
|
33
|
+
* process for the port: the replacement fails to bind and the restart reports
|
|
34
|
+
* "did not become ready". Wait until nothing answers on the port.
|
|
35
|
+
*
|
|
36
|
+
* Resolves true as soon as the port is free, false if it never frees.
|
|
37
|
+
*/
|
|
38
|
+
export declare function defaultWaitReleased(port: number, timeoutMs?: number): Promise<boolean>;
|
|
28
39
|
export interface ServiceExec {
|
|
29
40
|
argv: string[];
|
|
30
41
|
entry: string;
|
package/dist/service/macos.js
CHANGED
|
@@ -66,6 +66,28 @@ export async function defaultWaitReady(port, timeoutMs = 15000) {
|
|
|
66
66
|
}
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* `launchctl bootout` and `systemctl --user stop` both return before the job
|
|
71
|
+
* has actually gone, so a restart that starts immediately races the dying
|
|
72
|
+
* process for the port: the replacement fails to bind and the restart reports
|
|
73
|
+
* "did not become ready". Wait until nothing answers on the port.
|
|
74
|
+
*
|
|
75
|
+
* Resolves true as soon as the port is free, false if it never frees.
|
|
76
|
+
*/
|
|
77
|
+
export async function defaultWaitReleased(port, timeoutMs = 10000) {
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
const client = createServiceClient({ port, timeoutMs: 500 });
|
|
80
|
+
while (Date.now() < deadline) {
|
|
81
|
+
try {
|
|
82
|
+
await client.get("/health");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
69
91
|
const TRANSIENT_PATTERNS = ["/_npx/", "/.npm/_npx/", "/.worktrees/"];
|
|
70
92
|
export function resolveServiceExec(deps) {
|
|
71
93
|
const execPath = deps?.execPath ?? process.execPath;
|
|
@@ -258,6 +280,7 @@ export async function install(deps = {}) {
|
|
|
258
280
|
return;
|
|
259
281
|
}
|
|
260
282
|
const { home, uid, dataDir, print, run, lint, sleep, bootstrapTimeoutMs } = resolved(deps);
|
|
283
|
+
const version = deps.version ?? VERSION;
|
|
261
284
|
// Provision beside the data dir the service will use (identical to the
|
|
262
285
|
// QUOTACAP_HOME path in production; hermetic under injected test dirs).
|
|
263
286
|
await ensureConfig(path.join(dataDir, "config.json"));
|
|
@@ -300,7 +323,7 @@ export async function install(deps = {}) {
|
|
|
300
323
|
const samePlist = existing === plist;
|
|
301
324
|
const samePaths = !!meta &&
|
|
302
325
|
PROVIDER_BINS.every((bin) => (meta.providerPaths?.[bin] ?? null) === providerPaths[bin]);
|
|
303
|
-
if (samePlist && samePaths && meta?.version ===
|
|
326
|
+
if (samePlist && samePaths && meta?.version === version) {
|
|
304
327
|
await bootstrapWithRetry(run, uid, plistFile, {
|
|
305
328
|
expectReplace: false,
|
|
306
329
|
sleep,
|
|
@@ -312,7 +335,7 @@ export async function install(deps = {}) {
|
|
|
312
335
|
}
|
|
313
336
|
// Upgrade: refresh metadata, stop the old job, replace, load.
|
|
314
337
|
writeServiceMetadata({
|
|
315
|
-
version
|
|
338
|
+
version,
|
|
316
339
|
exec: argv[0],
|
|
317
340
|
...(entry ? { entry } : {}),
|
|
318
341
|
providerPaths,
|
|
@@ -322,7 +345,7 @@ export async function install(deps = {}) {
|
|
|
322
345
|
}
|
|
323
346
|
else {
|
|
324
347
|
writeServiceMetadata({
|
|
325
|
-
version
|
|
348
|
+
version,
|
|
326
349
|
exec: argv[0],
|
|
327
350
|
...(entry ? { entry } : {}),
|
|
328
351
|
providerPaths,
|
|
@@ -405,6 +428,14 @@ export async function restart(deps = {}) {
|
|
|
405
428
|
return;
|
|
406
429
|
}
|
|
407
430
|
await stop(deps);
|
|
431
|
+
// bootout is asynchronous: without this the replacement races the dying
|
|
432
|
+
// process for the port and start() fails its readiness check.
|
|
433
|
+
// deps.port keeps tests off the real daemon's port.
|
|
434
|
+
const port = deps.port ?? (await readConfig()).port;
|
|
435
|
+
const waitReleased = deps.waitReleased ?? defaultWaitReleased;
|
|
436
|
+
if (!(await waitReleased(port))) {
|
|
437
|
+
(deps.print ?? console.log)(`port ${port} is still in use after stopping; starting anyway`);
|
|
438
|
+
}
|
|
408
439
|
await start(deps);
|
|
409
440
|
}
|
|
410
441
|
function parsePrintOutput(out) {
|
|
@@ -13,9 +13,11 @@ export interface SystemdDeps {
|
|
|
13
13
|
runSystemctl?: (args: string[]) => string;
|
|
14
14
|
which?: (bin: string) => string | null;
|
|
15
15
|
waitReady?: (port: number) => Promise<boolean>;
|
|
16
|
+
waitReleased?: (port: number) => Promise<boolean>;
|
|
16
17
|
print?: (msg: string) => void;
|
|
17
18
|
error?: (msg: string) => void;
|
|
18
19
|
now?: () => Date;
|
|
20
|
+
version?: string;
|
|
19
21
|
}
|
|
20
22
|
export declare function formatSystemctlError(args: string[], detail: string): Error;
|
|
21
23
|
export declare function defaultRunSystemctl(args: string[]): string;
|
package/dist/service/systemd.js
CHANGED
|
@@ -11,7 +11,7 @@ import { VERSION } from "../version.js";
|
|
|
11
11
|
import { getDbPath, readServiceMetadata, writeServiceMetadata, } from "../config.js";
|
|
12
12
|
import { ensureConfig, readConfig } from "../config.js";
|
|
13
13
|
import { createServiceClient } from "../runtime/client.js";
|
|
14
|
-
import { SERVICE_LABEL, PROVIDER_BINS, buildChildPath, defaultWaitReady, defaultWhich, formatMissingProviders, formatStatus, foregroundGuidance, resolveProviderPaths, resolveServiceExec, } from "./macos.js";
|
|
14
|
+
import { SERVICE_LABEL, PROVIDER_BINS, buildChildPath, defaultWaitReady, defaultWaitReleased, defaultWhich, formatMissingProviders, formatStatus, foregroundGuidance, resolveProviderPaths, resolveServiceExec, } from "./macos.js";
|
|
15
15
|
export const SERVICE_UNIT = `${SERVICE_LABEL}.service`;
|
|
16
16
|
export function serviceSupported(platform = process.platform) {
|
|
17
17
|
return platform === "linux";
|
|
@@ -107,6 +107,7 @@ export async function install(deps = {}) {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
const { home, dataDir, print, run } = resolved(deps);
|
|
110
|
+
const version = deps.version ?? VERSION;
|
|
110
111
|
// Provision beside the data dir the service will use (identical to the
|
|
111
112
|
// QUOTACAP_HOME path in production; hermetic under injected test dirs).
|
|
112
113
|
await ensureConfig(path.join(dataDir, "config.json"));
|
|
@@ -141,7 +142,7 @@ export async function install(deps = {}) {
|
|
|
141
142
|
const sameUnit = existing === unit;
|
|
142
143
|
const samePaths = !!meta &&
|
|
143
144
|
PROVIDER_BINS.every((bin) => (meta.providerPaths?.[bin] ?? null) === providerPaths[bin]);
|
|
144
|
-
if (sameUnit && samePaths && meta?.version ===
|
|
145
|
+
if (sameUnit && samePaths && meta?.version === version) {
|
|
145
146
|
run(["enable", "--now", SERVICE_UNIT]);
|
|
146
147
|
const port = await waitForReady(deps, dataDir);
|
|
147
148
|
print(`service already installed and up to date; ready on port ${port}`);
|
|
@@ -149,7 +150,7 @@ export async function install(deps = {}) {
|
|
|
149
150
|
}
|
|
150
151
|
// Upgrade: refresh metadata, stop the old job, replace, load.
|
|
151
152
|
writeServiceMetadata({
|
|
152
|
-
version
|
|
153
|
+
version,
|
|
153
154
|
exec: argv[0],
|
|
154
155
|
...(entry ? { entry } : {}),
|
|
155
156
|
providerPaths,
|
|
@@ -159,7 +160,7 @@ export async function install(deps = {}) {
|
|
|
159
160
|
}
|
|
160
161
|
else {
|
|
161
162
|
writeServiceMetadata({
|
|
162
|
-
version
|
|
163
|
+
version,
|
|
163
164
|
exec: argv[0],
|
|
164
165
|
...(entry ? { entry } : {}),
|
|
165
166
|
providerPaths,
|
|
@@ -240,6 +241,13 @@ export async function restart(deps = {}) {
|
|
|
240
241
|
return;
|
|
241
242
|
}
|
|
242
243
|
await stop(deps);
|
|
244
|
+
// systemctl stop can return before the port is released; same race as launchd.
|
|
245
|
+
// deps.port mirrors start(): tests must never probe the real daemon's port.
|
|
246
|
+
const port = deps.port ?? (await readConfig()).port;
|
|
247
|
+
const waitReleased = deps.waitReleased ?? defaultWaitReleased;
|
|
248
|
+
if (!(await waitReleased(port))) {
|
|
249
|
+
(deps.print ?? console.log)(`port ${port} is still in use after stopping; starting anyway`);
|
|
250
|
+
}
|
|
243
251
|
await start(deps);
|
|
244
252
|
}
|
|
245
253
|
function parseShowOutput(out) {
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// generated by scripts/build-embed.mjs — do not edit
|
|
2
|
-
export const VERSION = "0.0.
|
|
2
|
+
export const VERSION = "0.0.25";
|
|
@@ -175,7 +175,7 @@ export function ResetRail({ providers, asOf, onSelectProvider, }) {
|
|
|
175
175
|
return (_jsxs("button", { "data-testid": "pin", type: "button", className: `pin ${pin.band} ${tierClass} ${alignClass}`, style: {
|
|
176
176
|
left: `${pin.positionPct}%`,
|
|
177
177
|
"--dot-color": paceColor,
|
|
178
|
-
}, "aria-label": `${name}: ${pace}, resets ${when}`, onClick: () => onSelectProvider(pin.id), children: [pin.band === "above" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-when", children: when }),
|
|
178
|
+
}, "aria-label": `${name}: ${pace}, resets ${when}`, onClick: () => onSelectProvider(pin.id), children: [pin.band === "above" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-when", children: pin.estimated ? `${when} (est.)` : when }), _jsx("span", { className: `pin-label ${paceCls}`, style: pinBorder, children: name }), _jsx("span", { className: "pin-stem" }), _jsx("span", { className: "pin-dot" })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-dot" }), _jsx("span", { className: "pin-stem" }), _jsx("span", { className: `pin-label ${paceCls}`, style: pinBorder, children: name }), _jsx("span", { className: "pin-when", children: pin.estimated ? `${when} (est.)` : when })] })), _jsxs("span", { className: "pin-tooltip", role: "tooltip", children: [_jsx("b", { children: name }), _jsx("span", { className: "pt-sep", children: "\u00B7" }), provider?.quota ? `${provider.quota.usedPct}% used` : "no readings", _jsx("span", { className: "pt-sep", children: "\u00B7" }), left ? `${left} left` : when, pin.estimated ? " (est.)" : ""] })] }, pin.id));
|
|
179
179
|
}), placement.clusters.map((cluster, index) => {
|
|
180
180
|
const alignClass = cluster.positionPct < 12 ? "pin-start" : cluster.positionPct > 88 ? "pin-end" : "";
|
|
181
181
|
const tierClass = cluster.tier > 0 ? `tier${cluster.tier + 1}` : "tier1";
|