@solcreek/cli 0.4.19 → 0.4.21

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.
@@ -62,7 +62,7 @@ export const loginCommand = defineCommand({
62
62
  * Default login: open browser → dashboard creates API key → redirect to localhost callback.
63
63
  */
64
64
  async function browserLogin() {
65
- const { port, state, waitForCallback, close } = startAuthServer();
65
+ const { port, state, waitForCallback, close } = await startAuthServer();
66
66
  const dashboardUrl = getDashboardUrl();
67
67
  const authUrl = `${dashboardUrl}/cli-auth?port=${port}&state=${state}`;
68
68
  consola.info("Opening browser to authenticate...");
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `creek metrics` — read project traffic + error aggregates from the
3
+ * control-plane metrics endpoint (Analytics Engine + zone GraphQL).
4
+ *
5
+ * Auth: requires `creek login`. Server enforces tenant isolation from
6
+ * the session — the CLI passes only the project slug.
7
+ *
8
+ * Output shape matches the Dashboard Analytics tab: total requests
9
+ * (including edge-cache hits), worker invocations, error count, and
10
+ * three breakdowns (HTTP method, scriptType, statusBucket). Live p50/
11
+ * p99 CPU times aren't exposed here yet; they live in the Workers
12
+ * GraphQL subset the Dashboard reads separately.
13
+ *
14
+ * Pair with `--json` to pipe into reports / dashboards.
15
+ */
16
+ export declare const metricsCommand: import("citty").CommandDef<{
17
+ json: {
18
+ type: "boolean";
19
+ description: string;
20
+ default: boolean;
21
+ };
22
+ yes: {
23
+ type: "boolean";
24
+ description: string;
25
+ default: boolean;
26
+ };
27
+ project: {
28
+ type: "string";
29
+ description: string;
30
+ };
31
+ period: {
32
+ type: "string";
33
+ description: string;
34
+ };
35
+ }>;
36
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1,167 @@
1
+ import { defineCommand } from "citty";
2
+ import consola from "consola";
3
+ import { CreekClient, resolveConfig, ConfigNotFoundError, } from "@solcreek/sdk";
4
+ import { getToken, getApiUrl } from "../utils/config.js";
5
+ import { globalArgs, resolveJsonMode, jsonOutput, AUTH_BREADCRUMBS, NO_PROJECT_BREADCRUMBS, } from "../utils/output.js";
6
+ const VALID_PERIODS = ["1h", "6h", "24h", "7d", "30d"];
7
+ /**
8
+ * `creek metrics` — read project traffic + error aggregates from the
9
+ * control-plane metrics endpoint (Analytics Engine + zone GraphQL).
10
+ *
11
+ * Auth: requires `creek login`. Server enforces tenant isolation from
12
+ * the session — the CLI passes only the project slug.
13
+ *
14
+ * Output shape matches the Dashboard Analytics tab: total requests
15
+ * (including edge-cache hits), worker invocations, error count, and
16
+ * three breakdowns (HTTP method, scriptType, statusBucket). Live p50/
17
+ * p99 CPU times aren't exposed here yet; they live in the Workers
18
+ * GraphQL subset the Dashboard reads separately.
19
+ *
20
+ * Pair with `--json` to pipe into reports / dashboards.
21
+ */
22
+ export const metricsCommand = defineCommand({
23
+ meta: {
24
+ name: "metrics",
25
+ description: "Read request + error metrics for a project. One-shot query — pair with --json for agent use or `| jq` piping.",
26
+ },
27
+ args: {
28
+ project: {
29
+ type: "string",
30
+ description: "Project slug. Defaults to creek.toml in cwd.",
31
+ },
32
+ period: {
33
+ type: "string",
34
+ description: `Time window. One of: ${VALID_PERIODS.join(", ")}. Default: 24h`,
35
+ },
36
+ ...globalArgs,
37
+ },
38
+ async run({ args }) {
39
+ const jsonMode = resolveJsonMode(args);
40
+ const token = getToken();
41
+ if (!token) {
42
+ if (jsonMode)
43
+ jsonOutput({ ok: false, error: "not_authenticated" }, 1, AUTH_BREADCRUMBS);
44
+ consola.error("Not authenticated. Run `creek login` first.");
45
+ process.exit(1);
46
+ }
47
+ const projectSlug = await resolveProjectSlug(args.project, jsonMode);
48
+ const period = validatePeriod(args.period, jsonMode);
49
+ const client = new CreekClient(getApiUrl(), token);
50
+ let response;
51
+ try {
52
+ response = await client.getMetrics(projectSlug, period);
53
+ }
54
+ catch (err) {
55
+ const msg = err instanceof Error ? err.message : String(err);
56
+ if (jsonMode)
57
+ jsonOutput({ ok: false, error: "metrics_failed", message: msg }, 1, []);
58
+ consola.error(`Failed to read metrics: ${msg}`);
59
+ process.exit(1);
60
+ }
61
+ if (jsonMode) {
62
+ jsonOutput({ ok: true, project: projectSlug, ...response }, 0, [
63
+ {
64
+ command: `creek logs --project ${projectSlug} --since ${period}`,
65
+ description: "Tail logs for the same window",
66
+ },
67
+ ]);
68
+ return;
69
+ }
70
+ printHuman(projectSlug, response);
71
+ },
72
+ });
73
+ function validatePeriod(raw, jsonMode) {
74
+ if (!raw)
75
+ return "24h";
76
+ if (VALID_PERIODS.includes(raw))
77
+ return raw;
78
+ const message = `Invalid --period: ${raw}. Valid: ${VALID_PERIODS.join(", ")}`;
79
+ if (jsonMode)
80
+ jsonOutput({ ok: false, error: "invalid_period", message }, 1, []);
81
+ consola.error(message);
82
+ process.exit(1);
83
+ }
84
+ async function resolveProjectSlug(override, jsonMode) {
85
+ if (override)
86
+ return override;
87
+ let resolved;
88
+ try {
89
+ resolved = resolveConfig(process.cwd());
90
+ }
91
+ catch (err) {
92
+ if (err instanceof ConfigNotFoundError) {
93
+ if (jsonMode)
94
+ jsonOutput({ ok: false, error: "no_project", message: "No project config in cwd" }, 1, NO_PROJECT_BREADCRUMBS);
95
+ consola.error("No project config in cwd. Pass --project <slug>.");
96
+ process.exit(1);
97
+ }
98
+ throw err;
99
+ }
100
+ return resolved.projectName;
101
+ }
102
+ // ─── Human output ────────────────────────────────────────────────────────
103
+ const COLOR = {
104
+ reset: "\x1b[0m",
105
+ dim: "\x1b[2m",
106
+ bold: "\x1b[1m",
107
+ red: "\x1b[31m",
108
+ yellow: "\x1b[33m",
109
+ green: "\x1b[32m",
110
+ cyan: "\x1b[36m",
111
+ gray: "\x1b[90m",
112
+ };
113
+ function tty() {
114
+ return process.stdout.isTTY ?? false;
115
+ }
116
+ function c(s, color) {
117
+ return tty() ? `${COLOR[color]}${s}${COLOR.reset}` : s;
118
+ }
119
+ function fmtNumber(n) {
120
+ if (n === 0)
121
+ return "0";
122
+ if (n < 1000)
123
+ return String(n);
124
+ if (n < 1_000_000)
125
+ return `${(n / 1000).toFixed(1)}k`;
126
+ return `${(n / 1_000_000).toFixed(2)}M`;
127
+ }
128
+ function fmtPct(n, total) {
129
+ if (total === 0)
130
+ return "0%";
131
+ return `${((n / total) * 100).toFixed(1)}%`;
132
+ }
133
+ function printHuman(slug, r) {
134
+ const { totals } = r;
135
+ const cachePct = fmtPct(totals.cachedReqs, totals.reqs);
136
+ const errPct = fmtPct(totals.errs, totals.invocations);
137
+ consola.log("");
138
+ consola.log(` ${c("⬡ creek metrics", "bold")} ${c(`${slug} · ${r.period}`, "dim")}`);
139
+ consola.log("");
140
+ consola.log(` Requests: ${c(fmtNumber(totals.reqs), "bold")}`);
141
+ consola.log(` Cache hits: ${fmtNumber(totals.cachedReqs)} ${c(`(${cachePct})`, "dim")}`);
142
+ consola.log(` Invocations: ${fmtNumber(totals.invocations)} ${c("worker ran", "dim")}`);
143
+ const errColor = totals.errs > 0 ? "red" : "green";
144
+ consola.log(` Errors: ${c(fmtNumber(totals.errs), errColor)} ${c(`(${errPct} of invocations)`, "dim")}`);
145
+ consola.log("");
146
+ printBreakdown("Method", r.breakdowns.method);
147
+ printBreakdown("Script type", r.breakdowns.scriptType);
148
+ printBreakdown("Status bucket", r.breakdowns.statusBucket);
149
+ }
150
+ function printBreakdown(title, rows) {
151
+ if (rows.length === 0)
152
+ return;
153
+ consola.log(` ${c(title, "dim")}`);
154
+ const total = rows.reduce((sum, r) => sum + r.reqs, 0);
155
+ const top = rows.slice(0, 5);
156
+ for (const row of top) {
157
+ const pct = fmtPct(row.reqs, total);
158
+ const label = row.label || c("(empty)", "gray");
159
+ const errBadge = row.errs > 0 ? c(` ${row.errs} err`, "red") : "";
160
+ consola.log(` ${label.padEnd(12)} ${fmtNumber(row.reqs).padStart(8)} ${c(pct.padStart(6), "dim")}${errBadge}`);
161
+ }
162
+ if (rows.length > top.length) {
163
+ consola.log(` ${c(`+${rows.length - top.length} more`, "dim")}`);
164
+ }
165
+ consola.log("");
166
+ }
167
+ //# sourceMappingURL=metrics.js.map
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { rollbackCommand } from "./commands/rollback.js";
19
19
  import { opsCommand } from "./commands/ops.js";
20
20
  import { queueCommand } from "./commands/queue.js";
21
21
  import { logsCommand } from "./commands/logs.js";
22
+ import { metricsCommand } from "./commands/metrics.js";
22
23
  import { doctorCommand } from "./commands/doctor.js";
23
24
  import { dbCommand } from "./commands/db.js";
24
25
  import { storageCommand } from "./commands/storage.js";
@@ -49,6 +50,7 @@ const main = defineCommand({
49
50
  projects: projectsCommand,
50
51
  deployments: deploymentsCommand,
51
52
  logs: logsCommand,
53
+ metrics: metricsCommand,
52
54
  doctor: doctorCommand,
53
55
  login: loginCommand,
54
56
  whoami: whoamiCommand,
@@ -13,10 +13,10 @@ export interface AuthCallbackResult {
13
13
  * 4. This server receives the callback, validates state, resolves the promise
14
14
  * 5. Server auto-closes
15
15
  */
16
- export declare function startAuthServer(): {
16
+ export declare function startAuthServer(): Promise<{
17
17
  port: number;
18
18
  state: string;
19
19
  waitForCallback: () => Promise<string>;
20
20
  close: () => void;
21
- };
21
+ }>;
22
22
  //# sourceMappingURL=auth-server.d.ts.map
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
11
11
  * 4. This server receives the callback, validates state, resolves the promise
12
12
  * 5. Server auto-closes
13
13
  */
14
- export function startAuthServer() {
14
+ export async function startAuthServer() {
15
15
  const state = randomBytes(16).toString("hex");
16
16
  let resolveCallback;
17
17
  let rejectCallback;
@@ -45,10 +45,23 @@ export function startAuthServer() {
45
45
  res.writeHead(404);
46
46
  res.end("Not found");
47
47
  });
48
- // Listen on port 0 = OS picks a random available port
49
- server.listen(0, "localhost");
48
+ // Listen on port 0 = OS picks a random available port.
49
+ // server.listen() is async — we MUST await the 'listening' event before
50
+ // reading server.address(), otherwise address() returns null and the
51
+ // callback URL ships ?port=0 to the dashboard (which correctly rejects
52
+ // with "Missing port or state parameter"). Regression seen in the wild
53
+ // on macOS — Node's behavior here is timing-dependent.
54
+ await new Promise((resolve, reject) => {
55
+ server.once("listening", () => resolve());
56
+ server.once("error", (err) => reject(err));
57
+ server.listen(0, "localhost");
58
+ });
50
59
  const address = server.address();
51
60
  const port = typeof address === "object" && address ? address.port : 0;
61
+ if (port === 0) {
62
+ server.close();
63
+ throw new Error("Could not determine local port for auth callback — server.address() returned null after listening.");
64
+ }
52
65
  // Timeout after 2 minutes
53
66
  const timeout = setTimeout(() => {
54
67
  rejectCallback(new Error("Login timed out after 2 minutes"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solcreek/cli",
3
- "version": "0.4.19",
3
+ "version": "0.4.21",
4
4
  "description": "CLI for the Creek deployment platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,7 +33,7 @@
33
33
  "esbuild": "^0.25.0",
34
34
  "smol-toml": "^1.3.1",
35
35
  "ws": "^8.20.0",
36
- "@solcreek/sdk": "0.4.8"
36
+ "@solcreek/sdk": "0.4.9"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@testing-library/dom": "^10.4.1",