quotacap 0.0.24 → 0.0.26
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 +28 -7
- package/dist/config.d.ts +25 -0
- package/dist/config.js +102 -0
- package/dist/runtime/service.js +11 -4
- package/dist/runtime/updates.d.ts +16 -0
- package/dist/runtime/updates.js +110 -21
- 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
|
@@ -4,7 +4,7 @@ import { ensureConfig, getDbPath } from "../config.js";
|
|
|
4
4
|
import { createServiceClient, ServiceError, ServiceUnavailable, } from "../runtime/client.js";
|
|
5
5
|
import { readClaim } from "../runtime/owner.js";
|
|
6
6
|
import { compareVersions } from "../runtime/versions.js";
|
|
7
|
-
import { detectChannel,
|
|
7
|
+
import { detectChannel, resolveLatestVersionDetailed, updateNpm, updateStandalone, writeUpdateCache, } from "../runtime/updates.js";
|
|
8
8
|
import { isServiceManaged, runServiceCommand } from "../service/index.js";
|
|
9
9
|
import { checkSkew, formatWedged, olderCliWarning, takeoverManaged, takeoverUnmanaged, WedgedError, } from "./takeover.js";
|
|
10
10
|
export function stoppedDaemonMessage(oldVersion, newVersion) {
|
|
@@ -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 };
|
|
@@ -134,6 +137,17 @@ export async function postUpdateTakeover(opts) {
|
|
|
134
137
|
function stripV(v) {
|
|
135
138
|
return v.startsWith("v") ? v.slice(1) : v;
|
|
136
139
|
}
|
|
140
|
+
// A rate-limited response names its reset (local HH:MM) and the pin escape
|
|
141
|
+
// hatch; every other failure keeps the generic network message.
|
|
142
|
+
function rateLimitError(resetsAtMs) {
|
|
143
|
+
const hint = "or run 'quotacap update --to <version>'";
|
|
144
|
+
if (resetsAtMs === null) {
|
|
145
|
+
return `GitHub's anonymous API rate limit is exhausted; retry later, ${hint}`;
|
|
146
|
+
}
|
|
147
|
+
const d = new Date(resetsAtMs);
|
|
148
|
+
const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
149
|
+
return `GitHub's anonymous API rate limit is exhausted (resets ${hhmm}); retry then, ${hint}`;
|
|
150
|
+
}
|
|
137
151
|
export function registerUpdateCommand(program, deps) {
|
|
138
152
|
const exit = deps?.exit ?? process.exit;
|
|
139
153
|
const fetchFn = deps?.fetchFn ?? fetch;
|
|
@@ -143,7 +157,7 @@ export function registerUpdateCommand(program, deps) {
|
|
|
143
157
|
.command("update")
|
|
144
158
|
.description("check for and apply updates")
|
|
145
159
|
.option("--check", "only check for updates, changing nothing")
|
|
146
|
-
.option("--
|
|
160
|
+
.option("--to <version>", "update to a specific version")
|
|
147
161
|
.option("--json", "machine-readable output")
|
|
148
162
|
.action(async (o) => {
|
|
149
163
|
const current = VERSION;
|
|
@@ -173,17 +187,24 @@ export function registerUpdateCommand(program, deps) {
|
|
|
173
187
|
exit(1);
|
|
174
188
|
return;
|
|
175
189
|
}
|
|
176
|
-
const pin = typeof o.
|
|
190
|
+
const pin = typeof o.to === "string" && o.to ? stripV(o.to) : null;
|
|
177
191
|
let latest = null;
|
|
192
|
+
let rateLimited = null;
|
|
178
193
|
try {
|
|
179
|
-
|
|
194
|
+
const resolved = await resolveLatestVersionDetailed({ fetchFn });
|
|
195
|
+
if (resolved.status === "ok")
|
|
196
|
+
latest = resolved.release;
|
|
197
|
+
else if (resolved.status === "rate-limited")
|
|
198
|
+
rateLimited = resolved;
|
|
180
199
|
}
|
|
181
200
|
catch {
|
|
182
201
|
latest = null;
|
|
183
202
|
}
|
|
184
203
|
const target = pin ?? latest?.version ?? null;
|
|
185
204
|
if (!target) {
|
|
186
|
-
const error =
|
|
205
|
+
const error = rateLimited
|
|
206
|
+
? rateLimitError(rateLimited.resetsAtMs)
|
|
207
|
+
: "could not resolve the latest release; check your network and retry";
|
|
187
208
|
if (asJson) {
|
|
188
209
|
emit({
|
|
189
210
|
channel,
|
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());
|
|
@@ -13,7 +13,21 @@ export interface ReleaseInfo {
|
|
|
13
13
|
url: string;
|
|
14
14
|
}
|
|
15
15
|
export declare function releasesApiUrl(): string;
|
|
16
|
+
export declare function releasesPageUrl(): string;
|
|
16
17
|
export declare function releaseDownloadBase(version: string): string;
|
|
18
|
+
export type LatestVersionResolution = {
|
|
19
|
+
status: "ok";
|
|
20
|
+
release: ReleaseInfo;
|
|
21
|
+
} | {
|
|
22
|
+
status: "rate-limited";
|
|
23
|
+
resetsAtMs: number | null;
|
|
24
|
+
} | {
|
|
25
|
+
status: "unavailable";
|
|
26
|
+
};
|
|
27
|
+
export declare function resolveLatestVersionDetailed(opts?: {
|
|
28
|
+
fetchFn?: typeof fetch;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}): Promise<LatestVersionResolution>;
|
|
17
31
|
export declare function resolveLatestVersion(opts?: {
|
|
18
32
|
fetchFn?: typeof fetch;
|
|
19
33
|
timeoutMs?: number;
|
|
@@ -23,11 +37,13 @@ export interface UpdateCache {
|
|
|
23
37
|
latest: string;
|
|
24
38
|
channel: string;
|
|
25
39
|
current: string;
|
|
40
|
+
lastFailureAt?: string;
|
|
26
41
|
}
|
|
27
42
|
export declare function updatesCachePath(): string;
|
|
28
43
|
export declare function readUpdateCache(p?: string): UpdateCache | null;
|
|
29
44
|
export declare function writeUpdateCache(cache: UpdateCache, p?: string): void;
|
|
30
45
|
export declare const UPDATE_CACHE_TTL_MS: number;
|
|
46
|
+
export declare const UPDATE_FAILURE_RETRY_MS: number;
|
|
31
47
|
export declare function updateCacheStale(cache: UpdateCache | null, nowMs?: number): boolean;
|
|
32
48
|
export declare function refreshUpdateCache(opts: {
|
|
33
49
|
channel: string;
|
package/dist/runtime/updates.js
CHANGED
|
@@ -129,6 +129,33 @@ export function releasesApiUrl() {
|
|
|
129
129
|
return `${base}/api/releases/latest`;
|
|
130
130
|
return "https://api.github.com/repos/carlosboeing/quotacap/releases/latest";
|
|
131
131
|
}
|
|
132
|
+
// Default version endpoint: the releases page answers 302 to the latest tag
|
|
133
|
+
// and sits outside the anonymous API rate-limit bucket shared with every
|
|
134
|
+
// other API consumer on the machine.
|
|
135
|
+
export function releasesPageUrl() {
|
|
136
|
+
return "https://github.com/carlosboeing/quotacap/releases/latest";
|
|
137
|
+
}
|
|
138
|
+
// Header read that tolerates real Headers and the plain-object headers that
|
|
139
|
+
// hermetic fetch mocks use.
|
|
140
|
+
function responseHeader(res, name) {
|
|
141
|
+
const headers = res.headers;
|
|
142
|
+
if (!headers)
|
|
143
|
+
return null;
|
|
144
|
+
if (typeof headers.get === "function") {
|
|
145
|
+
return headers.get(name);
|
|
146
|
+
}
|
|
147
|
+
if (typeof headers === "object") {
|
|
148
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
149
|
+
if (k.toLowerCase() === name && typeof v === "string")
|
|
150
|
+
return v;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function tagFromReleaseUrl(location) {
|
|
156
|
+
const m = /\/releases\/tag\/([^/?#]+)/.exec(location);
|
|
157
|
+
return m ? m[1] : "";
|
|
158
|
+
}
|
|
132
159
|
export function releaseDownloadBase(version) {
|
|
133
160
|
const base = process.env.QUOTACAP_RELEASE_BASE_URL?.replace(/\/+$/, "");
|
|
134
161
|
if (base)
|
|
@@ -137,32 +164,66 @@ export function releaseDownloadBase(version) {
|
|
|
137
164
|
? "https://github.com/carlosboeing/quotacap/releases/latest/download"
|
|
138
165
|
: `https://github.com/carlosboeing/quotacap/releases/download/v${version}`;
|
|
139
166
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
167
|
+
function rateLimitOrUnavailable(res) {
|
|
168
|
+
if (responseHeader(res, "x-ratelimit-remaining") === "0") {
|
|
169
|
+
const reset = responseHeader(res, "x-ratelimit-reset");
|
|
170
|
+
const secs = reset !== null && reset.trim() !== "" ? Number(reset) : NaN;
|
|
171
|
+
return {
|
|
172
|
+
status: "rate-limited",
|
|
173
|
+
resetsAtMs: Number.isFinite(secs) ? secs * 1000 : null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { status: "unavailable" };
|
|
177
|
+
}
|
|
178
|
+
// Full resolution outcome: the release, a rate-limited response carrying its
|
|
179
|
+
// reset time, or an undifferentiated failure. Never throws.
|
|
180
|
+
export async function resolveLatestVersionDetailed(opts = {}) {
|
|
143
181
|
const fetchFn = opts.fetchFn ?? fetch;
|
|
144
182
|
const timeoutMs = opts.timeoutMs ?? 10000;
|
|
183
|
+
const base = process.env.QUOTACAP_RELEASE_BASE_URL?.replace(/\/+$/, "");
|
|
145
184
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
185
|
+
if (base) {
|
|
186
|
+
const res = await fetchFn(releasesApiUrl(), {
|
|
187
|
+
headers: { accept: "application/vnd.github+json" },
|
|
188
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
189
|
+
});
|
|
190
|
+
if (res.ok) {
|
|
191
|
+
const body = (await res.json());
|
|
192
|
+
const tag = typeof body?.tag_name === "string" ? body.tag_name : "";
|
|
193
|
+
const version = tag.startsWith("v") ? tag.slice(1) : tag;
|
|
194
|
+
if (parseVersion(version)) {
|
|
195
|
+
const url = typeof body?.html_url === "string" && body.html_url
|
|
196
|
+
? body.html_url
|
|
197
|
+
: "https://github.com/carlosboeing/quotacap/releases/latest";
|
|
198
|
+
return { status: "ok", release: { version, url } };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return rateLimitOrUnavailable(res);
|
|
202
|
+
}
|
|
203
|
+
const res = await fetchFn(releasesPageUrl(), {
|
|
204
|
+
redirect: "manual",
|
|
148
205
|
signal: AbortSignal.timeout(timeoutMs),
|
|
149
206
|
});
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
: "https://github.com/carlosboeing/quotacap/releases/latest";
|
|
160
|
-
return { version, url };
|
|
207
|
+
if (res.status >= 300 && res.status < 400) {
|
|
208
|
+
const location = responseHeader(res, "location") ?? "";
|
|
209
|
+
const tag = tagFromReleaseUrl(location);
|
|
210
|
+
const version = tag.startsWith("v") ? tag.slice(1) : tag;
|
|
211
|
+
if (tag && parseVersion(version)) {
|
|
212
|
+
return { status: "ok", release: { version, url: location } };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return rateLimitOrUnavailable(res);
|
|
161
216
|
}
|
|
162
217
|
catch {
|
|
163
|
-
return
|
|
218
|
+
return { status: "unavailable" };
|
|
164
219
|
}
|
|
165
220
|
}
|
|
221
|
+
// Latest published version, or null when the release cannot be resolved
|
|
222
|
+
// (network failure, non-redirect, bad JSON, unparseable tag). Never throws.
|
|
223
|
+
export async function resolveLatestVersion(opts = {}) {
|
|
224
|
+
const r = await resolveLatestVersionDetailed(opts);
|
|
225
|
+
return r.status === "ok" ? r.release : null;
|
|
226
|
+
}
|
|
166
227
|
export function updatesCachePath() {
|
|
167
228
|
return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "updates.json");
|
|
168
229
|
}
|
|
@@ -180,6 +241,11 @@ export function readUpdateCache(p = updatesCachePath()) {
|
|
|
180
241
|
if (typeof parsed.channel !== "string" || typeof parsed.current !== "string") {
|
|
181
242
|
return null;
|
|
182
243
|
}
|
|
244
|
+
if (parsed.lastFailureAt !== undefined &&
|
|
245
|
+
(typeof parsed.lastFailureAt !== "string" ||
|
|
246
|
+
Number.isNaN(new Date(parsed.lastFailureAt).getTime()))) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
183
249
|
return parsed;
|
|
184
250
|
}
|
|
185
251
|
catch {
|
|
@@ -201,25 +267,48 @@ export function writeUpdateCache(cache, p = updatesCachePath()) {
|
|
|
201
267
|
catch { }
|
|
202
268
|
}
|
|
203
269
|
export const UPDATE_CACHE_TTL_MS = 24 * 3600 * 1000;
|
|
270
|
+
// Negative-cache floor: a failed refresh suppresses further network attempts
|
|
271
|
+
// for this long. Short against the daily TTL so a recovered endpoint is
|
|
272
|
+
// re-probed soon, long enough that status/advise/poll storms back off.
|
|
273
|
+
export const UPDATE_FAILURE_RETRY_MS = 30 * 60 * 1000;
|
|
204
274
|
export function updateCacheStale(cache, nowMs = Date.now()) {
|
|
205
275
|
if (!cache)
|
|
206
276
|
return true;
|
|
207
277
|
return nowMs - new Date(cache.checkedAt).getTime() >= UPDATE_CACHE_TTL_MS;
|
|
208
278
|
}
|
|
209
|
-
// Refresh the daily cache when older than 24 hours or absent.
|
|
210
|
-
//
|
|
279
|
+
// Refresh the daily cache when older than 24 hours or absent. A failure
|
|
280
|
+
// inside the retry floor performs no network call and keeps serving the
|
|
281
|
+
// previously cached latest; a fresh failure stamps lastFailureAt so the next
|
|
282
|
+
// attempt backs off. checkedAt is never stamped on failure, so the daily
|
|
283
|
+
// check is unaffected. The result is the cache to read from.
|
|
211
284
|
export async function refreshUpdateCache(opts) {
|
|
212
285
|
const cachePath = opts.cachePath ?? updatesCachePath();
|
|
213
286
|
const existing = readUpdateCache(cachePath);
|
|
214
287
|
const now = opts.now?.() ?? new Date();
|
|
215
288
|
if (!updateCacheStale(existing, now.getTime()))
|
|
216
289
|
return existing;
|
|
290
|
+
if (existing?.lastFailureAt &&
|
|
291
|
+
now.getTime() - new Date(existing.lastFailureAt).getTime() < UPDATE_FAILURE_RETRY_MS) {
|
|
292
|
+
return existing;
|
|
293
|
+
}
|
|
217
294
|
const latest = await resolveLatestVersion({
|
|
218
295
|
fetchFn: opts.fetchFn,
|
|
219
296
|
timeoutMs: opts.timeoutMs ?? 3000,
|
|
220
297
|
});
|
|
221
|
-
if (!latest)
|
|
222
|
-
|
|
298
|
+
if (!latest) {
|
|
299
|
+
if (!existing)
|
|
300
|
+
return existing;
|
|
301
|
+
// Re-read before stamping: a concurrent refresh may have succeeded while
|
|
302
|
+
// our request was in flight, and stamping our stale snapshot would
|
|
303
|
+
// clobber its newer write and suppress retries for the floor.
|
|
304
|
+
const current = readUpdateCache(cachePath);
|
|
305
|
+
if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
|
|
306
|
+
return current;
|
|
307
|
+
}
|
|
308
|
+
const stamped = { ...existing, lastFailureAt: now.toISOString() };
|
|
309
|
+
writeUpdateCache(stamped, cachePath);
|
|
310
|
+
return stamped;
|
|
311
|
+
}
|
|
223
312
|
const fresh = {
|
|
224
313
|
checkedAt: now.toISOString(),
|
|
225
314
|
latest: latest.version,
|
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;
|