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 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/cli/index.ts:56`). It does not listen on `0.0.0.0`. There is no LAN surface.
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:37-48`. `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.
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
 
@@ -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;
@@ -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 code = await execService(["restart"]);
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 restart exited ${code}`);
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) {
@@ -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), and the unmanaged path leaves
40
- // the daemon stopped with guidance instead of foreground-starting.
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
  }
@@ -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, then strict validation (a present-but-
119
- // invalid file still fails naming the field). The daemon always leaves a
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());
@@ -73,7 +73,10 @@ export async function runServiceCommand(args, opts = {}, deps = {}) {
73
73
  try {
74
74
  switch (verb) {
75
75
  case "install":
76
- await backend.install(deps);
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);
@@ -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;
@@ -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 === 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: 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: 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;
@@ -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 === 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: 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: 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.24";
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 }), _jsxs("span", { className: `pin-label ${paceCls}`, style: pinBorder, children: [name.split(" ")[0], pin.estimated ? " (est.)" : ""] }), _jsx("span", { className: "pin-stem" }), _jsx("span", { className: "pin-dot" })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "pin-dot" }), _jsx("span", { className: "pin-stem" }), _jsxs("span", { className: `pin-label ${paceCls}`, style: pinBorder, children: [name.split(" ")[0], pin.estimated ? " (est.)" : ""] }), _jsx("span", { className: "pin-when", children: 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));
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";