quotacap 0.0.9 → 0.0.10

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/dist/cli/index.js CHANGED
@@ -62,6 +62,12 @@ program.command("web").option("--port <n>").action(async (o) => {
62
62
  ensureDbDir();
63
63
  const db = openDb(getDbPath());
64
64
  migrate(db);
65
+ const { isDaemonRunning, startDaemon } = await import("../daemon.js");
66
+ if (!isDaemonRunning()) {
67
+ const started = await startDaemon();
68
+ if (!started.alreadyRunning)
69
+ console.log("daemon started (auto)");
70
+ }
65
71
  const app = buildApp(db);
66
72
  const port = o.port ? parseInt(o.port) : (await readConfig()).port;
67
73
  await app.listen({ port, host: "127.0.0.1" });
@@ -75,14 +81,18 @@ program.command("init").action(async () => {
75
81
  });
76
82
  program.command("daemon").option("--foreground", "keep foreground (default: true)").action(async (o) => {
77
83
  const { startDaemon } = await import("../daemon.js");
78
- const { timer } = await startDaemon();
84
+ const started = await startDaemon();
85
+ if (started.alreadyRunning) {
86
+ console.log(`daemon already running (pid ${started.alreadyRunning})`);
87
+ return;
88
+ }
79
89
  console.log("QuotaCap daemon started" + (o.foreground !== false ? " (foreground)" : ""));
80
90
  // keep alive until SIGINT/SIGTERM — timer is ref'd so event loop stays alive
81
- process.on("SIGINT", () => { clearInterval(timer); process.exit(0); });
82
- process.on("SIGTERM", () => { clearInterval(timer); process.exit(0); });
91
+ process.on("SIGINT", () => { started.stop(); process.exit(0); });
92
+ process.on("SIGTERM", () => { started.stop(); process.exit(0); });
83
93
  // explicitly keep process alive if interval was somehow unref'd elsewhere
84
- if (timer.ref)
85
- timer.ref();
94
+ if (started.timer.ref)
95
+ started.timer.ref();
86
96
  });
87
97
  program.command("mcp").description("start MCP server (stdio over HTTP)").action(async () => {
88
98
  const mod = await import("../mcp/server.js");
package/dist/config.js CHANGED
@@ -5,12 +5,12 @@ import { z } from "zod";
5
5
  export function getConfigPath(p) {
6
6
  if (p)
7
7
  return p;
8
- return path.join(os.homedir(), ".quotacap", "config.json");
8
+ return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "config.json");
9
9
  }
10
10
  export function getDbPath(p) {
11
11
  if (p)
12
12
  return p;
13
- return path.join(os.homedir(), ".quotacap", "quotacap.db");
13
+ return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "quotacap.db");
14
14
  }
15
15
  const ConfigSchema = z.object({
16
16
  port: z.number().default(8787),
package/dist/daemon.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare function isDaemonRunning(file?: string): boolean;
1
2
  export declare function pollOnce(db: any, enabled: string[]): Promise<({
2
3
  provider: string;
3
4
  status: "fulfilled";
@@ -15,8 +16,14 @@ export declare function pollOnce(db: any, enabled: string[]): Promise<({
15
16
  value?: undefined;
16
17
  })[]>;
17
18
  export declare function startDaemon(): Promise<{
19
+ alreadyRunning: string;
20
+ db: any;
21
+ timer: any;
22
+ stop: () => void;
23
+ } | {
18
24
  db: any;
19
25
  timer: NodeJS.Timeout;
26
+ alreadyRunning: string | undefined;
20
27
  stop: () => void;
21
28
  }>;
22
29
  export declare const start: typeof startDaemon;
package/dist/daemon.js CHANGED
@@ -1,5 +1,34 @@
1
1
  import { pollAll } from "./adapters/index.js";
2
2
  import { upsertQuota } from "./store/quotas.js";
3
+ import { getDbPath, readConfig } from "./config.js";
4
+ import { openDb, migrate } from "./store/db.js";
5
+ import { execFileSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ function pidFile() {
9
+ return path.join(path.dirname(getDbPath()), "daemon.pid");
10
+ }
11
+ // True when the pidfile names a live process that still runs quotacap — the
12
+ // command check keeps a recycled pid from being mistaken for a running daemon
13
+ // and matches both the compiled binary and `node dist/cli/index.js`.
14
+ export function isDaemonRunning(file = pidFile()) {
15
+ try {
16
+ const pid = parseInt(fs.readFileSync(file, "utf8").trim(), 10);
17
+ if (!Number.isInteger(pid) || pid <= 0)
18
+ return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).trim();
26
+ return cmd.includes("quotacap") || cmd.includes("cli/index.js");
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
3
32
  export async function pollOnce(db, enabled) {
4
33
  const results = await pollAll(enabled);
5
34
  for (const r of results) {
@@ -12,19 +41,30 @@ export async function pollOnce(db, enabled) {
12
41
  return results;
13
42
  }
14
43
  export async function startDaemon() {
15
- const { getDbPath, readConfig } = await import("./config.js");
16
- const { openDb, migrate } = await import("./store/db.js");
44
+ if (isDaemonRunning()) {
45
+ const existing = fs.readFileSync(pidFile(), "utf8").trim();
46
+ return { alreadyRunning: existing, db: null, timer: null, stop: () => { } };
47
+ }
48
+ const dbDir = path.dirname(getDbPath());
49
+ fs.mkdirSync(dbDir, { recursive: true });
17
50
  const db = openDb(getDbPath());
18
51
  migrate(db);
52
+ fs.mkdirSync(path.dirname(pidFile()), { recursive: true });
53
+ fs.writeFileSync(pidFile(), String(process.pid));
19
54
  const cfg = await readConfig();
20
55
  const intervalMs = (cfg.pollMinutes ?? 15) * 60 * 1000;
21
56
  // initial poll (fire-and-forget, don't block start)
22
57
  pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] initial poll failed", e?.message ?? String(e)));
23
58
  const jitter = Math.floor(Math.random() * 5000);
24
59
  const timer = setInterval(() => { pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] interval poll failed", e?.message ?? String(e))); }, intervalMs + jitter);
25
- // keep event loop alive — this is the only long-lived process per design
26
- // (previous unref() caused immediate exit after the initial poll)
27
- return { db, timer, stop: () => clearInterval(timer) };
60
+ const stop = () => {
61
+ clearInterval(timer);
62
+ try {
63
+ fs.rmSync(pidFile(), { force: true });
64
+ }
65
+ catch { }
66
+ };
67
+ return { db, timer, alreadyRunning: undefined, stop };
28
68
  }
29
69
  // alias for plan's daemon.start() naming
30
70
  export const start = startDaemon;
@@ -1 +1,2 @@
1
+ export declare function formatResetDate(iso: string): string;
1
2
  export declare function renderQuotasTable(quotas: any[], advisories?: any[]): string;
@@ -1,11 +1,20 @@
1
1
  // The one table every surface renders: MCP tools, CLI status, web dashboard.
2
2
  // Quotas carry used/resets; advisories add days-left, burn and waste analysis.
3
3
  // Without advisories the analysis columns render as placeholders.
4
+ export function formatResetDate(iso) {
5
+ return new Date(iso).toLocaleString(undefined, {
6
+ day: "numeric",
7
+ month: "short",
8
+ year: "numeric",
9
+ hour: "numeric",
10
+ minute: "2-digit",
11
+ });
12
+ }
4
13
  export function renderQuotasTable(quotas, advisories = []) {
5
14
  const rows = quotas.map((q) => {
6
15
  const a = advisories.find((x) => x.provider === q.provider);
7
16
  const used = Math.round(q.usedPct ?? 0);
8
- const resets = q.resetsAt ? new Date(q.resetsAt).toLocaleString() : "—";
17
+ const resets = q.resetsAt ? formatResetDate(q.resetsAt) : "—";
9
18
  const daysLeft = a?.daysLeft != null ? a.daysLeft.toFixed(1) : "—";
10
19
  const ideal = a?.idealRate != null ? `${Math.round(a.idealRate)}%/day` : "—";
11
20
  const burn = a?.burnMeasured ? `${a.burnRate.toFixed(1)}%/day` : a != null ? "collecting…" : "—";
@@ -62,6 +62,12 @@ program.command("web").option("--port <n>").action(async (o) => {
62
62
  ensureDbDir();
63
63
  const db = openDb(getDbPath());
64
64
  migrate(db);
65
+ const { isDaemonRunning, startDaemon } = await import("../daemon.js");
66
+ if (!isDaemonRunning()) {
67
+ const started = await startDaemon();
68
+ if (!started.alreadyRunning)
69
+ console.log("daemon started (auto)");
70
+ }
65
71
  const app = buildApp(db);
66
72
  const port = o.port ? parseInt(o.port) : (await readConfig()).port;
67
73
  await app.listen({ port, host: "127.0.0.1" });
@@ -75,14 +81,18 @@ program.command("init").action(async () => {
75
81
  });
76
82
  program.command("daemon").option("--foreground", "keep foreground (default: true)").action(async (o) => {
77
83
  const { startDaemon } = await import("../daemon.js");
78
- const { timer } = await startDaemon();
84
+ const started = await startDaemon();
85
+ if (started.alreadyRunning) {
86
+ console.log(`daemon already running (pid ${started.alreadyRunning})`);
87
+ return;
88
+ }
79
89
  console.log("QuotaCap daemon started" + (o.foreground !== false ? " (foreground)" : ""));
80
90
  // keep alive until SIGINT/SIGTERM — timer is ref'd so event loop stays alive
81
- process.on("SIGINT", () => { clearInterval(timer); process.exit(0); });
82
- process.on("SIGTERM", () => { clearInterval(timer); process.exit(0); });
91
+ process.on("SIGINT", () => { started.stop(); process.exit(0); });
92
+ process.on("SIGTERM", () => { started.stop(); process.exit(0); });
83
93
  // explicitly keep process alive if interval was somehow unref'd elsewhere
84
- if (timer.ref)
85
- timer.ref();
94
+ if (started.timer.ref)
95
+ started.timer.ref();
86
96
  });
87
97
  program.command("mcp").description("start MCP server (stdio over HTTP)").action(async () => {
88
98
  const mod = await import("../mcp/server.js");
@@ -5,12 +5,12 @@ import { z } from "zod";
5
5
  export function getConfigPath(p) {
6
6
  if (p)
7
7
  return p;
8
- return path.join(os.homedir(), ".quotacap", "config.json");
8
+ return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "config.json");
9
9
  }
10
10
  export function getDbPath(p) {
11
11
  if (p)
12
12
  return p;
13
- return path.join(os.homedir(), ".quotacap", "quotacap.db");
13
+ return path.join(process.env.QUOTACAP_HOME ?? os.homedir(), ".quotacap", "quotacap.db");
14
14
  }
15
15
  const ConfigSchema = z.object({
16
16
  port: z.number().default(8787),
@@ -1,3 +1,4 @@
1
+ export declare function isDaemonRunning(file?: string): boolean;
1
2
  export declare function pollOnce(db: any, enabled: string[]): Promise<({
2
3
  provider: string;
3
4
  status: "fulfilled";
@@ -15,8 +16,14 @@ export declare function pollOnce(db: any, enabled: string[]): Promise<({
15
16
  value?: undefined;
16
17
  })[]>;
17
18
  export declare function startDaemon(): Promise<{
19
+ alreadyRunning: string;
20
+ db: any;
21
+ timer: any;
22
+ stop: () => void;
23
+ } | {
18
24
  db: any;
19
25
  timer: NodeJS.Timeout;
26
+ alreadyRunning: string | undefined;
20
27
  stop: () => void;
21
28
  }>;
22
29
  export declare const start: typeof startDaemon;
@@ -1,5 +1,34 @@
1
1
  import { pollAll } from "./adapters/index.js";
2
2
  import { upsertQuota } from "./store/quotas.js";
3
+ import { getDbPath, readConfig } from "./config.js";
4
+ import { openDb, migrate } from "./store/db.js";
5
+ import { execFileSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ function pidFile() {
9
+ return path.join(path.dirname(getDbPath()), "daemon.pid");
10
+ }
11
+ // True when the pidfile names a live process that still runs quotacap — the
12
+ // command check keeps a recycled pid from being mistaken for a running daemon
13
+ // and matches both the compiled binary and `node dist/cli/index.js`.
14
+ export function isDaemonRunning(file = pidFile()) {
15
+ try {
16
+ const pid = parseInt(fs.readFileSync(file, "utf8").trim(), 10);
17
+ if (!Number.isInteger(pid) || pid <= 0)
18
+ return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).trim();
26
+ return cmd.includes("quotacap") || cmd.includes("cli/index.js");
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
3
32
  export async function pollOnce(db, enabled) {
4
33
  const results = await pollAll(enabled);
5
34
  for (const r of results) {
@@ -12,19 +41,30 @@ export async function pollOnce(db, enabled) {
12
41
  return results;
13
42
  }
14
43
  export async function startDaemon() {
15
- const { getDbPath, readConfig } = await import("./config.js");
16
- const { openDb, migrate } = await import("./store/db.js");
44
+ if (isDaemonRunning()) {
45
+ const existing = fs.readFileSync(pidFile(), "utf8").trim();
46
+ return { alreadyRunning: existing, db: null, timer: null, stop: () => { } };
47
+ }
48
+ const dbDir = path.dirname(getDbPath());
49
+ fs.mkdirSync(dbDir, { recursive: true });
17
50
  const db = openDb(getDbPath());
18
51
  migrate(db);
52
+ fs.mkdirSync(path.dirname(pidFile()), { recursive: true });
53
+ fs.writeFileSync(pidFile(), String(process.pid));
19
54
  const cfg = await readConfig();
20
55
  const intervalMs = (cfg.pollMinutes ?? 15) * 60 * 1000;
21
56
  // initial poll (fire-and-forget, don't block start)
22
57
  pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] initial poll failed", e?.message ?? String(e)));
23
58
  const jitter = Math.floor(Math.random() * 5000);
24
59
  const timer = setInterval(() => { pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] interval poll failed", e?.message ?? String(e))); }, intervalMs + jitter);
25
- // keep event loop alive — this is the only long-lived process per design
26
- // (previous unref() caused immediate exit after the initial poll)
27
- return { db, timer, stop: () => clearInterval(timer) };
60
+ const stop = () => {
61
+ clearInterval(timer);
62
+ try {
63
+ fs.rmSync(pidFile(), { force: true });
64
+ }
65
+ catch { }
66
+ };
67
+ return { db, timer, alreadyRunning: undefined, stop };
28
68
  }
29
69
  // alias for plan's daemon.start() naming
30
70
  export const start = startDaemon;
@@ -1 +1,2 @@
1
+ export declare function formatResetDate(iso: string): string;
1
2
  export declare function renderQuotasTable(quotas: any[], advisories?: any[]): string;
@@ -1,11 +1,20 @@
1
1
  // The one table every surface renders: MCP tools, CLI status, web dashboard.
2
2
  // Quotas carry used/resets; advisories add days-left, burn and waste analysis.
3
3
  // Without advisories the analysis columns render as placeholders.
4
+ export function formatResetDate(iso) {
5
+ return new Date(iso).toLocaleString(undefined, {
6
+ day: "numeric",
7
+ month: "short",
8
+ year: "numeric",
9
+ hour: "numeric",
10
+ minute: "2-digit",
11
+ });
12
+ }
4
13
  export function renderQuotasTable(quotas, advisories = []) {
5
14
  const rows = quotas.map((q) => {
6
15
  const a = advisories.find((x) => x.provider === q.provider);
7
16
  const used = Math.round(q.usedPct ?? 0);
8
- const resets = q.resetsAt ? new Date(q.resetsAt).toLocaleString() : "—";
17
+ const resets = q.resetsAt ? formatResetDate(q.resetsAt) : "—";
9
18
  const daysLeft = a?.daysLeft != null ? a.daysLeft.toFixed(1) : "—";
10
19
  const ideal = a?.idealRate != null ? `${Math.round(a.idealRate)}%/day` : "—";
11
20
  const burn = a?.burnMeasured ? `${a.burnRate.toFixed(1)}%/day` : a != null ? "collecting…" : "—";
@@ -3,4 +3,4 @@ export declare function getLatestByProvider(db: any, provider: string): any;
3
3
  export declare function getAllLatest(db: any): any;
4
4
  export declare const getQuotas: typeof getAllLatest;
5
5
  export declare function getSnapshots(db: any): any;
6
- export declare function getBurnRates(db: any): Map<string, number>;
6
+ export declare function getBurnRates(db: any, now?: number): Map<string, number>;
@@ -26,25 +26,29 @@ export function getAllLatest(db) {
26
26
  // alias for plan's getQuotas naming
27
27
  export const getQuotas = getAllLatest;
28
28
  export function getSnapshots(db) { return db.prepare(`SELECT * FROM snapshots ORDER BY day DESC`).all(); }
29
- export function getBurnRates(db) {
30
- const rows = db.prepare(`SELECT day, provider, used_pct FROM snapshots`).all();
29
+ export function getBurnRates(db, now = Date.now()) {
30
+ // Burn is the used-pct delta over a real rolling window of poll history
31
+ // (up to 24h), so calendar-day boundaries and poll timing cannot skew it.
32
+ const rows = db.prepare(`SELECT provider, used_pct, fetched_at FROM quotas`).all();
31
33
  const byProvider = new Map();
32
34
  for (const r of rows) {
35
+ const t = new Date(r.fetched_at).getTime();
36
+ if (Number.isNaN(t))
37
+ continue;
33
38
  const pts = byProvider.get(r.provider) ?? [];
34
- pts.push({ day: r.day, usedPct: r.used_pct });
39
+ pts.push({ usedPct: r.used_pct, t });
35
40
  byProvider.set(r.provider, pts);
36
41
  }
37
42
  const out = new Map();
38
43
  for (const [provider, pts] of byProvider) {
39
- if (pts.length < 2)
40
- continue;
41
- const sorted = [...pts].sort((a, b) => a.day.localeCompare(b.day));
42
- const first = sorted[0];
43
- const last = sorted[sorted.length - 1];
44
- const days = (new Date(last.day).getTime() - new Date(first.day).getTime()) / 86400000;
45
- if (days < 1)
44
+ const sorted = pts.sort((a, b) => a.t - b.t);
45
+ const latest = sorted[sorted.length - 1];
46
+ const cutoff = latest.t - 86400000;
47
+ const windowStart = sorted.find((p) => p.t >= cutoff) ?? sorted[0];
48
+ const days = (latest.t - windowStart.t) / 86400000;
49
+ if (sorted.length < 2 || days < 1 / 24)
46
50
  continue;
47
- const burn = (last.usedPct - first.usedPct) / days;
51
+ const burn = (latest.usedPct - windowStart.usedPct) / days;
48
52
  if (burn >= 0)
49
53
  out.set(provider, burn);
50
54
  }
@@ -1,2 +1,2 @@
1
1
  // generated by scripts/build-embed.mjs — do not edit
2
- export const VERSION = "0.0.9";
2
+ export const VERSION = "0.0.10";