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.
Files changed (114) hide show
  1. package/README.md +4 -6
  2. package/dist/adapters/grok.js +2 -2
  3. package/dist/adapters/pty.d.ts +18 -0
  4. package/dist/adapters/pty.js +124 -32
  5. package/dist/advisory/snapshot.d.ts +6 -0
  6. package/dist/advisory/types.d.ts +7 -0
  7. package/dist/cli/advise.js +72 -6
  8. package/dist/cli/clients.d.ts +10 -0
  9. package/dist/cli/index.js +13 -8
  10. package/dist/cli/init.d.ts +2 -0
  11. package/dist/cli/init.js +29 -0
  12. package/dist/cli/runtime.d.ts +21 -0
  13. package/dist/cli/runtime.js +169 -42
  14. package/dist/cli/snapshot-source.d.ts +1 -0
  15. package/dist/cli/snapshot-source.js +9 -0
  16. package/dist/cli/status.js +70 -4
  17. package/dist/cli/takeover.d.ts +64 -0
  18. package/dist/cli/takeover.js +175 -0
  19. package/dist/cli/update.d.ts +61 -0
  20. package/dist/cli/update.js +392 -0
  21. package/dist/config.d.ts +7 -0
  22. package/dist/config.js +28 -0
  23. package/dist/http/server.d.ts +8 -0
  24. package/dist/http/server.js +40 -0
  25. package/dist/runtime/poll.d.ts +2 -0
  26. package/dist/runtime/poll.js +7 -0
  27. package/dist/runtime/service.js +19 -2
  28. package/dist/runtime/updates.d.ts +74 -0
  29. package/dist/runtime/updates.js +445 -0
  30. package/dist/runtime/versions.d.ts +2 -0
  31. package/dist/runtime/versions.js +26 -0
  32. package/dist/service/index.d.ts +7 -2
  33. package/dist/service/index.js +67 -11
  34. package/dist/service/macos.d.ts +4 -0
  35. package/dist/service/macos.js +61 -10
  36. package/dist/service/systemd.d.ts +40 -0
  37. package/dist/service/systemd.js +387 -0
  38. package/dist/src/adapters/grok.js +2 -2
  39. package/dist/src/adapters/pty.d.ts +18 -0
  40. package/dist/src/adapters/pty.js +124 -32
  41. package/dist/src/advisory/snapshot.d.ts +6 -0
  42. package/dist/src/advisory/types.d.ts +7 -0
  43. package/dist/src/cli/advise.js +72 -6
  44. package/dist/src/cli/clients.d.ts +10 -0
  45. package/dist/src/cli/index.js +13 -8
  46. package/dist/src/cli/init.d.ts +2 -0
  47. package/dist/src/cli/init.js +29 -0
  48. package/dist/src/cli/runtime.d.ts +21 -0
  49. package/dist/src/cli/runtime.js +169 -42
  50. package/dist/src/cli/snapshot-source.d.ts +1 -0
  51. package/dist/src/cli/snapshot-source.js +9 -0
  52. package/dist/src/cli/status.js +70 -4
  53. package/dist/src/cli/takeover.d.ts +64 -0
  54. package/dist/src/cli/takeover.js +175 -0
  55. package/dist/src/cli/update.d.ts +61 -0
  56. package/dist/src/cli/update.js +392 -0
  57. package/dist/src/config.d.ts +7 -0
  58. package/dist/src/config.js +28 -0
  59. package/dist/src/http/server.d.ts +8 -0
  60. package/dist/src/http/server.js +40 -0
  61. package/dist/src/runtime/poll.d.ts +2 -0
  62. package/dist/src/runtime/poll.js +7 -0
  63. package/dist/src/runtime/service.js +19 -2
  64. package/dist/src/runtime/updates.d.ts +74 -0
  65. package/dist/src/runtime/updates.js +445 -0
  66. package/dist/src/runtime/versions.d.ts +2 -0
  67. package/dist/src/runtime/versions.js +26 -0
  68. package/dist/src/service/index.d.ts +7 -2
  69. package/dist/src/service/index.js +67 -11
  70. package/dist/src/service/macos.d.ts +4 -0
  71. package/dist/src/service/macos.js +61 -10
  72. package/dist/src/service/systemd.d.ts +40 -0
  73. package/dist/src/service/systemd.js +387 -0
  74. package/dist/src/version.js +1 -1
  75. package/dist/src/webAssets.js +1 -1
  76. package/dist/src/webHtml.js +1 -1
  77. package/dist/tests/adapters/grok.test.js +10 -0
  78. package/dist/tests/adapters/pty-perms.test.d.ts +1 -0
  79. package/dist/tests/adapters/pty-perms.test.js +78 -0
  80. package/dist/tests/cli/advise.test.js +73 -0
  81. package/dist/tests/cli/cli.test.js +1 -1
  82. package/dist/tests/cli/init.test.d.ts +1 -0
  83. package/dist/tests/cli/init.test.js +55 -0
  84. package/dist/tests/cli/launcher.test.d.ts +1 -0
  85. package/dist/tests/cli/launcher.test.js +271 -0
  86. package/dist/tests/cli/status.test.js +113 -0
  87. package/dist/tests/cli/takeover.test.d.ts +1 -0
  88. package/dist/tests/cli/takeover.test.js +325 -0
  89. package/dist/tests/cli/update.test.d.ts +1 -0
  90. package/dist/tests/cli/update.test.js +382 -0
  91. package/dist/tests/runtime/cli.test.js +206 -6
  92. package/dist/tests/runtime/http.test.js +79 -0
  93. package/dist/tests/runtime/poll.test.js +28 -0
  94. package/dist/tests/runtime/updates.test.d.ts +1 -0
  95. package/dist/tests/runtime/updates.test.js +509 -0
  96. package/dist/tests/service/macos.test.js +103 -2
  97. package/dist/tests/service/systemd.test.d.ts +1 -0
  98. package/dist/tests/service/systemd.test.js +330 -0
  99. package/dist/tests/smoke/dashboard.e2e.d.ts +1 -0
  100. package/dist/tests/smoke/dashboard.e2e.js +142 -0
  101. package/dist/tests/smoke/playwright.config.d.ts +2 -0
  102. package/dist/tests/smoke/playwright.config.js +11 -0
  103. package/dist/tests/web/dashboard.e2e.js +1 -1
  104. package/dist/tests/web/header.test.js +26 -0
  105. package/dist/version.js +1 -1
  106. package/dist/web/src/components/Header.js +5 -1
  107. package/dist/web/src/state.d.ts +7 -0
  108. package/dist/webAssets.js +1 -1
  109. package/dist/webHtml.js +1 -1
  110. package/package.json +2 -3
  111. package/web/dist/assets/{index-CyUkWdwJ.css → index-BEBqgvq8.css} +1 -1
  112. package/web/dist/assets/{index-PN4RpMKG.js → index-DZfttQgo.js} +1 -1
  113. package/web/dist/index.html +2 -2
  114. package/scripts/fix-pty-perms.mjs +0 -21
package/README.md CHANGED
@@ -39,21 +39,19 @@ npm install -g quotacap
39
39
  ```
40
40
 
41
41
  The binary is a self-contained executable from GitHub Releases (shipped as `quotacap-<os>-<arch>.tar.gz` with a `pty` sidecar for Kimi/Codex/Grok); `install.sh` handles the tarball and sidecar transparently.
42
+ One command takes you from zero to a running tracker: the installer verifies checksums, provisions config, registers the background login service, and opens the dashboard. Re-running it upgrades in place with no flags. Pass `--no-service` (foreground only) or `--no-open` (SSH sessions) to skip steps.
42
43
  The npm package runs the same CLI on Node. To run without a global install, replace `quotacap` with `npx quotacap` in any command, for example `npx quotacap web`.
43
44
 
44
45
  ## Quick start
45
46
 
46
47
  ```bash
47
- # Terminal 1: leave this running
48
- quotacap init # writes ~/.quotacap/config.json
49
- quotacap web # dashboard at http://localhost:8787
50
-
51
- # Terminal 2: after the dashboard table fills
48
+ quotacap # opens the dashboard, starting the service if needed
52
49
  quotacap status
53
50
  quotacap advise
51
+ quotacap update # upgrade to the latest release
54
52
  ```
55
53
 
56
- `web` stays in the foreground and starts the daemon.
54
+ No setup ceremony: config is provisioned on first use, and bare `quotacap` is the dashboard launcher. `quotacap web --foreground` and `quotacap daemon` keep the old hold-the-terminal mode for SSH, containers, and supervisors.
57
55
 
58
56
  ## MCP
59
57
 
@@ -10,7 +10,7 @@ const ABBR_MONTHS = {
10
10
  jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
11
11
  };
12
12
  function parseGrokReset(raw, now) {
13
- const m = raw.trim().match(/^([A-Za-z]+)\s+(\d{1,2}),\s+(\d{1,2}):(\d{2})$/);
13
+ const m = raw.trim().match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{1,2}):(\d{2})$/);
14
14
  if (!m)
15
15
  return null;
16
16
  const monStr = m[1].toLowerCase();
@@ -73,7 +73,7 @@ export function parseGrokTui(text, now = new Date()) {
73
73
  if (Number.isFinite(v) && v >= 0)
74
74
  creditsUsd = v;
75
75
  }
76
- const mReset = cleaned.match(/Resets:\s*([A-Za-z]+\s+\d+,\s+\d+:\d+)/i);
76
+ const mReset = cleaned.match(/Resets:\s*([A-Za-z]+\s+\d+,\s*\d+:\d+)/i);
77
77
  let resetsAt = null;
78
78
  let estimated = false;
79
79
  if (mReset) {
@@ -20,4 +20,22 @@ export interface PtyRunOptions {
20
20
  /** Diagnostic label for the tracked child (adapter id). */
21
21
  label?: string;
22
22
  }
23
+ export interface SpawnHelperRepair {
24
+ helper: string;
25
+ ok: boolean;
26
+ error?: string;
27
+ }
28
+ /**
29
+ * Lazy replacement for the removed postinstall: best-effort `chmod 0o755`
30
+ * over every prebuilds entry `spawn-helper` under the given node-pty roots.
31
+ * Missing roots, missing prebuild dirs, and missing helpers are skipped
32
+ * silently; only attempted repairs are recorded.
33
+ */
34
+ export declare function repairSpawnHelpers(roots: string[], chmod?: (p: string, mode: number) => void): SpawnHelperRepair[];
35
+ /**
36
+ * Human diagnostic for failed repairs, or null when nothing failed.
37
+ * Load-bearing for root-owned trees (e.g. `sudo` global installs) where the
38
+ * invoking user cannot repair: without it the failure would go opaque again.
39
+ */
40
+ export declare function describeSpawnHelperRepairs(outcomes: SpawnHelperRepair[]): string | null;
23
41
  export declare function runPty(opts: PtyRunOptions): Promise<string>;
@@ -18,17 +18,71 @@ function regexTest(re, s) {
18
18
  const fresh = new RegExp(re.source, re.flags.replace("g", ""));
19
19
  return fresh.test(s);
20
20
  }
21
+ /**
22
+ * Lazy replacement for the removed postinstall: best-effort `chmod 0o755`
23
+ * over every prebuilds entry `spawn-helper` under the given node-pty roots.
24
+ * Missing roots, missing prebuild dirs, and missing helpers are skipped
25
+ * silently; only attempted repairs are recorded.
26
+ */
27
+ export function repairSpawnHelpers(roots, chmod = fs.chmodSync) {
28
+ const outcomes = [];
29
+ const seen = new Set();
30
+ for (const root of roots) {
31
+ let entries;
32
+ try {
33
+ entries = fs.readdirSync(path.join(root, "prebuilds"));
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ for (const entry of entries) {
39
+ const helper = path.join(root, "prebuilds", entry, "spawn-helper");
40
+ if (seen.has(helper))
41
+ continue;
42
+ seen.add(helper);
43
+ try {
44
+ if (!fs.existsSync(helper))
45
+ continue;
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ try {
51
+ chmod(helper, 0o755);
52
+ outcomes.push({ helper, ok: true });
53
+ }
54
+ catch (e) {
55
+ outcomes.push({ helper, ok: false, error: String(e?.message ?? e) });
56
+ }
57
+ }
58
+ }
59
+ return outcomes;
60
+ }
61
+ /**
62
+ * Human diagnostic for failed repairs, or null when nothing failed.
63
+ * Load-bearing for root-owned trees (e.g. `sudo` global installs) where the
64
+ * invoking user cannot repair: without it the failure would go opaque again.
65
+ */
66
+ export function describeSpawnHelperRepairs(outcomes) {
67
+ const failed = outcomes.filter((o) => !o.ok);
68
+ if (failed.length === 0)
69
+ return null;
70
+ const lines = failed.map((o) => `could not make ${o.helper} executable (${o.error ?? "unknown error"})`);
71
+ return (`${lines.join("; ")} — the install tree is likely root-owned (e.g. installed with sudo); ` +
72
+ `reinstall without sudo or fix the install's ownership so the invoking user can write it`);
73
+ }
74
+ function nodePtyPackageRoot(resolvedEntry) {
75
+ const dir = path.dirname(resolvedEntry);
76
+ // node-pty's main entry is lib/index.js; the package root is two levels up.
77
+ if (path.basename(dir) === "lib" && /^index\.[cm]?js$/.test(path.basename(resolvedEntry))) {
78
+ return path.dirname(dir);
79
+ }
80
+ return dir;
81
+ }
21
82
  let ptyMod = null;
22
83
  function getPty() {
23
84
  if (ptyMod)
24
85
  return ptyMod;
25
- try {
26
- const require = createRequire(import.meta.url);
27
- ptyMod = require("node-pty");
28
- if (ptyMod?.spawn)
29
- return ptyMod;
30
- }
31
- catch { }
32
86
  // Compiled binary (bun --compile) has no node_modules; try sidecar locations
33
87
  const execDir = path.dirname(process.execPath);
34
88
  const platformArch = `${process.platform}-${process.arch}`;
@@ -40,6 +94,29 @@ function getPty() {
40
94
  path.join(os.homedir(), ".local", "share", "quotacap", "pty", platformArch, "node-pty"),
41
95
  path.join(os.homedir(), ".quotacap", "pty", "node-pty"),
42
96
  ];
97
+ // Best-effort +x before requiring: the resolvable npm root (when present)
98
+ // plus every existing sidecar root. Successful repairs stay silent.
99
+ const repairRoots = [];
100
+ try {
101
+ const require0 = createRequire(import.meta.url);
102
+ repairRoots.push(nodePtyPackageRoot(require0.resolve("node-pty")));
103
+ }
104
+ catch { }
105
+ for (const c of candidates) {
106
+ try {
107
+ if (fs.existsSync(c))
108
+ repairRoots.push(c);
109
+ }
110
+ catch { }
111
+ }
112
+ const repairs = repairSpawnHelpers(repairRoots);
113
+ try {
114
+ const require = createRequire(import.meta.url);
115
+ ptyMod = require("node-pty");
116
+ if (ptyMod?.spawn)
117
+ return ptyMod;
118
+ }
119
+ catch { }
43
120
  for (const c of candidates) {
44
121
  try {
45
122
  if (!fs.existsSync(c))
@@ -65,7 +142,9 @@ function getPty() {
65
142
  }
66
143
  catch { }
67
144
  }
68
- throw new Error("pty: node-pty not available — install with build tools (Xcode on macOS, build-essential + python3 on Linux) or use exec-based adapters only; for compiled binaries, ensure the pty sidecar is installed alongside the binary (see install.sh)");
145
+ const repairNote = describeSpawnHelperRepairs(repairs);
146
+ throw new Error("pty: node-pty not available — install with build tools (Xcode on macOS, build-essential + python3 on Linux) or use exec-based adapters only; for compiled binaries, ensure the pty sidecar is installed alongside the binary (see install.sh)" +
147
+ (repairNote ? `; ${repairNote}` : ""));
69
148
  }
70
149
  function isBunRuntime() {
71
150
  return typeof globalThis.Bun !== "undefined" && typeof globalThis.Bun.spawn === "function";
@@ -97,6 +176,11 @@ async function runPtyBun(opts) {
97
176
  let transcript = "";
98
177
  let exited = false;
99
178
  let exitCode;
179
+ // Genuine PTY via the Bun.spawn `terminal` option (verified on Bun 1.3.11):
180
+ // the child's stdin, stdout, and stderr are all the PTY slave, input goes
181
+ // through proc.terminal.write, and merged output arrives in `data`.
182
+ // proc.stdin/stdout/stderr are null in this mode.
183
+ const decoder = new TextDecoder();
100
184
  const proc = BunGlobal.spawn([opts.file, ...(opts.args ?? [])], {
101
185
  cwd: opts.cwd ?? process.cwd(),
102
186
  env: {
@@ -104,25 +188,26 @@ async function runPtyBun(opts) {
104
188
  ...(opts.env ?? {}),
105
189
  TERM: "xterm-256color",
106
190
  },
107
- stdin: "pipe",
108
- pty: { cols, rows },
191
+ terminal: {
192
+ cols,
193
+ rows,
194
+ data(_term, chunk) {
195
+ try {
196
+ transcript += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
197
+ }
198
+ catch { }
199
+ },
200
+ },
109
201
  });
110
- // Capture stdout (pty merges stdout+stderr)
111
- const decoder = new TextDecoder();
112
- const reader = proc.stdout.getReader();
113
- let reading = true;
114
- const readLoop = (async () => {
202
+ if (!proc.terminal) {
203
+ // Old Bun runtimes silently ignore the unknown `terminal` option, so an
204
+ // absent proc.terminal is the fail-loud signal — never run half-attached.
115
205
  try {
116
- while (reading) {
117
- const { value, done } = await reader.read();
118
- if (done)
119
- break;
120
- if (value)
121
- transcript += decoder.decode(value);
122
- }
206
+ proc.kill();
123
207
  }
124
208
  catch { }
125
- })();
209
+ throw new Error("pty: Bun runtime lacks terminal (PTY) support (Bun.spawn terminal option unavailable) — rebuild the standalone binary with a newer Bun version");
210
+ }
126
211
  // Poll for exit
127
212
  const exitPoll = (async () => {
128
213
  try {
@@ -132,6 +217,7 @@ async function runPtyBun(opts) {
132
217
  }
133
218
  catch { }
134
219
  })();
220
+ void exitPoll;
135
221
  const checkCap = () => {
136
222
  if (Buffer.byteLength(transcript, "utf8") > maxBytes) {
137
223
  throw new Error(`pty transcript exceeds ${maxBytes} bytes`);
@@ -169,6 +255,10 @@ async function runPtyBun(opts) {
169
255
  proc.kill();
170
256
  }
171
257
  catch { }
258
+ try {
259
+ proc.terminal?.close();
260
+ }
261
+ catch { }
172
262
  }, opts.label ?? opts.file);
173
263
  try {
174
264
  if (opts.readyRegex) {
@@ -211,9 +301,7 @@ async function runPtyBun(opts) {
211
301
  if (exited)
212
302
  throw new Error(`pty exited before input (code ${exitCode})`);
213
303
  try {
214
- proc.stdin.write(opts.input);
215
- if (typeof proc.stdin.flush === "function")
216
- proc.stdin.flush();
304
+ proc.terminal.write(opts.input);
217
305
  }
218
306
  catch (e) {
219
307
  throw new Error(`pty write failed: ${e.message}`);
@@ -257,12 +345,17 @@ async function runPtyBun(opts) {
257
345
  }
258
346
  }
259
347
  await kill();
260
- reading = false;
348
+ // Drain late `data` callbacks, then close the terminal and flush the
349
+ // streamed decoder before the final checks.
350
+ await delay(200);
261
351
  try {
262
- reader.cancel();
352
+ proc.terminal.close();
353
+ }
354
+ catch { }
355
+ try {
356
+ transcript += decoder.decode();
263
357
  }
264
358
  catch { }
265
- await Promise.race([readLoop, delay(200)]);
266
359
  checkCap();
267
360
  checkAborted();
268
361
  const finalClean = stripAnsi(transcript);
@@ -272,12 +365,11 @@ async function runPtyBun(opts) {
272
365
  return transcript;
273
366
  }
274
367
  catch (e) {
275
- reading = false;
368
+ await kill();
276
369
  try {
277
- reader.cancel();
370
+ proc.terminal.close();
278
371
  }
279
372
  catch { }
280
- await kill();
281
373
  unregisterChild();
282
374
  teardownAbort();
283
375
  throw e;
@@ -9,6 +9,12 @@ export interface SnapshotOptions {
9
9
  polling: "idle" | "in-progress" | "cooldown";
10
10
  lastCompletedPollAt: string | null;
11
11
  version: string;
12
+ update?: {
13
+ current: string;
14
+ latest: string | null;
15
+ upToDate: boolean;
16
+ checkedAt: string | null;
17
+ };
12
18
  };
13
19
  }
14
20
  export declare function buildSnapshot(db: any, opts: SnapshotOptions): StateSnapshot;
@@ -42,6 +42,12 @@ export interface ProviderSnapshot {
42
42
  exclusionReason: ExclusionReason;
43
43
  advisory: Advisory | null;
44
44
  }
45
+ export interface UpdateStatus {
46
+ current: string;
47
+ latest: string | null;
48
+ upToDate: boolean;
49
+ checkedAt: string | null;
50
+ }
45
51
  export interface StateSnapshot {
46
52
  asOf: string;
47
53
  runtime: {
@@ -50,6 +56,7 @@ export interface StateSnapshot {
50
56
  polling: "idle" | "in-progress" | "cooldown";
51
57
  lastCompletedPollAt: string | null;
52
58
  version: string;
59
+ update?: UpdateStatus;
53
60
  };
54
61
  providers: ProviderSnapshot[];
55
62
  recommendation: Recommendation;
@@ -1,13 +1,22 @@
1
1
  import { projectRecommendationResponse } from "../advisory/snapshot.js";
2
2
  import { validateTask } from "../advisory/validate.js";
3
- import { getDbPath, readConfig } from "../config.js";
3
+ import { ensureConfig, getDbPath } from "../config.js";
4
4
  import { createServiceClient } from "../runtime/client.js";
5
- import { ClientError, OFFLINE_LABEL, emptySnapshot, resolveSnapshot } from "./snapshot-source.js";
5
+ import { VERSION } from "../version.js";
6
+ import { isServiceManaged, runServiceCommand } from "../service/index.js";
7
+ import { detectChannel, printUpdateFooter, readUpdateCache, refreshUpdateCache, updateFooter, } from "../runtime/updates.js";
8
+ import { ClientError, OFFLINE_LABEL, emptySnapshot, offlineOnlyClient, resolveSnapshot } from "./snapshot-source.js";
9
+ import { checkSkew, execSkewWarning, olderCliWarning, takeoverManaged, unmanagedSkewWarning, } from "./takeover.js";
6
10
  export function registerAdviseCommand(program, deps) {
7
11
  const createClient = deps.createClient ?? ((o) => createServiceClient(o));
8
12
  const openDb = deps.openDb;
9
13
  const now = deps.now ?? (() => new Date());
10
14
  const exit = deps.exit ?? process.exit;
15
+ const execService = deps.execService ?? ((args, o) => runServiceCommand(args, o ?? {}, {}));
16
+ const isManaged = deps.isManaged ?? isServiceManaged;
17
+ const takeoverOpts = deps.takeoverOpts ?? {};
18
+ const checkUpdates = deps.checkUpdates ??
19
+ (() => refreshUpdateCache({ channel: detectChannel(), current: VERSION }));
11
20
  program
12
21
  .command("advise")
13
22
  .description("show which provider to use next (same advice for every task)")
@@ -26,8 +35,61 @@ export function registerAdviseCommand(program, deps) {
26
35
  return;
27
36
  }
28
37
  const t = now();
29
- const cfg = await readConfig();
30
- const client = createClient({ port: cfg.port, timeoutMs: 2000 });
38
+ const { config: cfg } = await ensureConfig();
39
+ let client = createClient({ port: cfg.port, timeoutMs: 2000 });
40
+ // Skew pre-check mirrors status: warn on exec-only and older-CLI skew,
41
+ // take the managed path when newer, and never spawn a foreground daemon
42
+ // here — unmanaged skew serves the stored snapshot instead of failing.
43
+ let health = null;
44
+ try {
45
+ health = await client.get("/health");
46
+ }
47
+ catch {
48
+ health = null;
49
+ }
50
+ if (health?.ok) {
51
+ const skew = checkSkew(health, VERSION, process.execPath);
52
+ if (skew === "exec-only") {
53
+ console.error(execSkewWarning(String(health.exec)));
54
+ }
55
+ else if (skew === "cli-older") {
56
+ console.error(olderCliWarning(String(health.version ?? "unknown")));
57
+ }
58
+ else if (skew === "cli-newer") {
59
+ const managed = await isManaged().catch(() => false);
60
+ if (managed) {
61
+ try {
62
+ const r = await takeoverManaged({
63
+ port: cfg.port,
64
+ health,
65
+ createClient,
66
+ execService,
67
+ ...takeoverOpts,
68
+ });
69
+ console.error(r.message);
70
+ }
71
+ catch (e) {
72
+ console.error(e instanceof Error ? e.message : String(e));
73
+ exit(1);
74
+ return;
75
+ }
76
+ }
77
+ else {
78
+ console.error(unmanagedSkewWarning(String(health.version ?? "unknown")));
79
+ client = offlineOnlyClient();
80
+ }
81
+ }
82
+ }
83
+ // Passive daily update signal: opportunistically refresh the shared
84
+ // cache, then surface staleness as a stderr footer (never in --json).
85
+ let updateCache = null;
86
+ try {
87
+ updateCache = await checkUpdates();
88
+ }
89
+ catch {
90
+ updateCache = readUpdateCache();
91
+ }
92
+ const footer = updateFooter(VERSION, updateCache?.latest ?? null);
31
93
  let resolved;
32
94
  try {
33
95
  resolved = await resolveSnapshot({
@@ -43,8 +105,10 @@ export function registerAdviseCommand(program, deps) {
43
105
  const rec = projectRecommendationResponse(emptySnapshot(t), task);
44
106
  if (o.json)
45
107
  console.log(JSON.stringify(rec, null, 2));
46
- else
108
+ else {
47
109
  console.log(`${rec.use}: ${rec.reason}`);
110
+ printUpdateFooter(o, footer);
111
+ }
48
112
  return;
49
113
  }
50
114
  console.error(e instanceof Error ? e.message : String(e));
@@ -56,7 +120,9 @@ export function registerAdviseCommand(program, deps) {
56
120
  const rec = projectRecommendationResponse(resolved.snapshot, task);
57
121
  if (o.json)
58
122
  console.log(JSON.stringify(rec, null, 2));
59
- else
123
+ else {
60
124
  console.log(`${rec.use}: ${rec.reason}`);
125
+ printUpdateFooter(o, footer);
126
+ }
61
127
  });
62
128
  }
@@ -1,5 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import type { ServiceClient } from "../runtime/client.js";
3
+ import type { SleepFn } from "./takeover.js";
4
+ import type { UpdateCache } from "../runtime/updates.js";
3
5
  export interface CreateClientOptions {
4
6
  port: number;
5
7
  token?: string;
@@ -14,5 +16,13 @@ export interface ClientCommandDeps {
14
16
  };
15
17
  now?: () => Date;
16
18
  exit?: (code: number) => void;
19
+ execService?: (args: string[], opts?: Record<string, any>) => Promise<number>;
20
+ isManaged?: () => Promise<boolean>;
21
+ takeoverOpts?: {
22
+ sleep?: SleepFn;
23
+ timeoutMs?: number;
24
+ readToken?: () => string | undefined;
25
+ };
26
+ checkUpdates?: () => Promise<UpdateCache | null>;
17
27
  }
18
28
  export declare function registerClientCommands(program: Command, deps?: ClientCommandDeps): void;
package/dist/cli/index.js CHANGED
@@ -1,17 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { VERSION } from "../version.js";
4
- import { registerRuntimeCommands } from "./runtime.js";
4
+ import { launchWeb, registerRuntimeCommands } from "./runtime.js";
5
5
  import { registerClientCommands } from "./clients.js";
6
+ import { registerInitCommand } from "./init.js";
7
+ import { registerUpdateCommand } from "./update.js";
6
8
  const program = new Command();
7
9
  program.name("quotacap").version(VERSION);
8
10
  program.command("version").action(() => console.log(VERSION));
9
- program.command("init").action(async () => {
10
- const { readConfig, writeConfig } = await import("../config.js");
11
- const c = await readConfig();
12
- await writeConfig(c);
13
- console.log(JSON.stringify(c, null, 2));
14
- });
11
+ registerInitCommand(program);
15
12
  program.command("mcp").description("start MCP server (stdio over HTTP)").action(async () => {
16
13
  const mod = await import("../mcp/server.js");
17
14
  // if run with --help, commander handles it before action; this is the real server
@@ -19,4 +16,12 @@ program.command("mcp").description("start MCP server (stdio over HTTP)").action(
19
16
  });
20
17
  registerRuntimeCommands(program);
21
18
  registerClientCommands(program);
22
- program.parseAsync();
19
+ registerUpdateCommand(program);
20
+ // Bare `quotacap` with no arguments at all opens the dashboard through the
21
+ // shared launcher; flags and subcommands keep Commander behavior.
22
+ if (process.argv.length === 2) {
23
+ launchWeb({});
24
+ }
25
+ else {
26
+ program.parseAsync();
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerInitCommand(program: Command): void;
@@ -0,0 +1,29 @@
1
+ import fsSync from "node:fs";
2
+ import { defaultConfig, ensureConfig, getConfigPath, readConfig, writeConfig, } from "../config.js";
3
+ const NEXT_STEP = "next: run 'quotacap web' to open the dashboard, 'quotacap service status' to check the background service";
4
+ export function registerInitCommand(program) {
5
+ program
6
+ .command("init")
7
+ .description("show or reset the config file")
8
+ .option("--quiet", "print nothing on success")
9
+ .option("--force", "rewrite defaults after moving the existing file to config.json.bak")
10
+ .action(async (o) => {
11
+ const p = getConfigPath();
12
+ if (o.force && fsSync.existsSync(p)) {
13
+ fsSync.renameSync(p, `${p}.bak`);
14
+ await writeConfig(defaultConfig());
15
+ if (o.quiet)
16
+ return;
17
+ console.log(JSON.stringify(await readConfig(), null, 2));
18
+ console.error(`rewrote default config at ${p} (previous saved to ${p}.bak)\n${NEXT_STEP}`);
19
+ return;
20
+ }
21
+ const r = await ensureConfig();
22
+ if (o.quiet)
23
+ return;
24
+ console.log(JSON.stringify(r.config, null, 2));
25
+ console.error(r.created
26
+ ? `wrote default config to ${p}\n${NEXT_STEP}`
27
+ : `config already exists at ${p} (use --force to rewrite defaults)\n${NEXT_STEP}`);
28
+ });
29
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Command } from "commander";
2
2
  import { startService, type StartServiceOptions } from "../runtime/service.js";
3
+ import { type ServiceClient } from "../runtime/client.js";
4
+ import { type SleepFn } from "./takeover.js";
3
5
  export type StartServiceFn = (opts?: StartServiceOptions) => ReturnType<typeof startService>;
4
6
  export type OpenBrowserFn = (url: string) => Promise<void> | void;
5
7
  export type ExecServiceFn = (args: string[], opts?: Record<string, any>) => Promise<number>;
@@ -8,5 +10,24 @@ export interface RuntimeCommandDeps {
8
10
  openBrowser?: OpenBrowserFn;
9
11
  execService?: ExecServiceFn;
10
12
  exit?: (code: number) => void;
13
+ createClient?: (opts: {
14
+ port: number;
15
+ token?: string;
16
+ timeoutMs: number;
17
+ }) => ServiceClient;
18
+ isManaged?: () => Promise<boolean>;
19
+ takeoverOpts?: {
20
+ sleep?: SleepFn;
21
+ timeoutMs?: number;
22
+ readToken?: () => string | undefined;
23
+ };
24
+ checkUpdates?: () => Promise<unknown>;
25
+ isInstalled?: () => Promise<boolean>;
26
+ serviceSupported?: () => boolean;
11
27
  }
28
+ export interface WebLaunchOptions {
29
+ port?: string;
30
+ foreground?: boolean;
31
+ }
32
+ export declare function launchWeb(o: WebLaunchOptions, deps?: RuntimeCommandDeps): Promise<void>;
12
33
  export declare function registerRuntimeCommands(program: Command, deps?: RuntimeCommandDeps): void;