@ze-norm/cli 0.3.0 → 0.5.0

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.
@@ -13,6 +13,8 @@ export declare class ZenormClient {
13
13
  constructor(opts?: ApiClientOptions);
14
14
  private getHeaders;
15
15
  private isLocalBaseUrl;
16
+ /** True when this client targets a localhost API (dev-bypass eligible). */
17
+ isLocalDevTarget(): boolean;
16
18
  private request;
17
19
  get<T>(path: string): Promise<T>;
18
20
  post<T>(path: string, body?: unknown): Promise<T>;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA0BD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,KAAK,CAAgB;gBAEjB,IAAI,CAAC,EAAE,gBAAgB;IAKnC,OAAO,CAAC,UAAU;IAqBlB,OAAO,CAAC,cAAc;YASR,OAAO;IAwCf,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIjD,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhD,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIlD,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIzC;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,WAAW,CAAC;CAG7C"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA0BD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,qBAAa,YAAY;IACvB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,KAAK,CAAgB;gBAEjB,IAAI,CAAC,EAAE,gBAAgB;IAKnC,OAAO,CAAC,UAAU;IAqBlB,OAAO,CAAC,cAAc;IAStB,2EAA2E;IAC3E,gBAAgB,IAAI,OAAO;YAIb,OAAO;IAwCf,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIjD,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIhD,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC;IAIlD,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC;IAIzC;;OAEG;IACG,cAAc,IAAI,OAAO,CAAC,WAAW,CAAC;CAG7C"}
@@ -57,6 +57,10 @@ export class ZenormClient {
57
57
  return false;
58
58
  }
59
59
  }
60
+ /** True when this client targets a localhost API (dev-bypass eligible). */
61
+ isLocalDevTarget() {
62
+ return this.isLocalBaseUrl();
63
+ }
60
64
  async request(method, path, body) {
61
65
  const url = `${this.baseUrl}${path}`;
62
66
  const init = {
@@ -1 +1 @@
1
- {"version":3,"file":"device-flow.d.ts","sourceRoot":"","sources":["../../src/auth/device-flow.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAgJpD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CA4DpE"}
1
+ {"version":3,"file":"device-flow.d.ts","sourceRoot":"","sources":["../../src/auth/device-flow.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AA+RpD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAkDpE"}
@@ -4,10 +4,43 @@ import { exec } from "node:child_process";
4
4
  import { platform } from "node:os";
5
5
  import { URL, URLSearchParams } from "node:url";
6
6
  import { log } from "../util/logger.js";
7
- import { PRODUCTION_CLERK_CLIENT_ID, PRODUCTION_CLERK_ISSUER, } from "../config/defaults.js";
7
+ import { CLI_CALLBACK_PORTS, PRODUCTION_CLERK_CLIENT_ID, PRODUCTION_CLERK_ISSUER, PRODUCTION_WEB_URL, } from "../config/defaults.js";
8
+ /**
9
+ * Find the first pre-registered callback port that is free to bind. Clerk
10
+ * matches redirect_uris exactly, so the local listener must use one of the
11
+ * ports registered on the OAuth app — not a random OS-assigned port.
12
+ */
13
+ async function pickFreeCallbackPort() {
14
+ for (const candidate of CLI_CALLBACK_PORTS) {
15
+ const free = await new Promise((resolve) => {
16
+ const srv = createServer();
17
+ srv.once("error", () => resolve(false));
18
+ srv.listen(candidate, "127.0.0.1", () => {
19
+ srv.close(() => resolve(true));
20
+ });
21
+ });
22
+ if (free)
23
+ return candidate;
24
+ }
25
+ throw new Error(`All CLI callback ports are in use (${CLI_CALLBACK_PORTS.join(", ")}). ` +
26
+ `Free one of them and run \`zenorm login\` again.`);
27
+ }
8
28
  function getClerkIssuer() {
9
29
  return process.env["ZENORM_CLERK_ISSUER"] ?? PRODUCTION_CLERK_ISSUER;
10
30
  }
31
+ /** Where the browser tab lands after a successful login. */
32
+ function getWebUrl() {
33
+ return process.env["ZENORM_WEB_URL"] ?? PRODUCTION_WEB_URL;
34
+ }
35
+ /** Escape user-influenced text before interpolating into the result page HTML. */
36
+ function escapeHtml(value) {
37
+ return value
38
+ .replace(/&/g, "&amp;")
39
+ .replace(/</g, "&lt;")
40
+ .replace(/>/g, "&gt;")
41
+ .replace(/"/g, "&quot;")
42
+ .replace(/'/g, "&#39;");
43
+ }
11
44
  function getClerkClientId() {
12
45
  return (process.env["ZENORM_CLERK_CLIENT_ID"] ??
13
46
  process.env["VITE_CLERK_PUBLISHABLE_KEY"] ??
@@ -42,6 +75,100 @@ function tryOpenBrowser(url) {
42
75
  }
43
76
  });
44
77
  }
78
+ /**
79
+ * Render a self-contained, branded error page for the localhost callback.
80
+ *
81
+ * Only the failure paths render a page — a successful login 302-redirects the
82
+ * browser into the app instead. Served by the raw `http` server (no
83
+ * React/bundler available), so the page is fully inline: dark theme, ZeNorm
84
+ * wordmark, and the same card framing the web app uses on /sign-in.
85
+ */
86
+ function renderCallbackPage(heading, body) {
87
+ const accent = "#f87171";
88
+ const glyph = "&#33;";
89
+ return `<!doctype html>
90
+ <html lang="en">
91
+ <head>
92
+ <meta charset="utf-8" />
93
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
94
+ <title>ZeNorm CLI &middot; ${heading}</title>
95
+ <style>
96
+ :root { color-scheme: dark; }
97
+ * { box-sizing: border-box; }
98
+ body {
99
+ margin: 0;
100
+ min-height: 100vh;
101
+ display: grid;
102
+ place-items: center;
103
+ padding: 24px;
104
+ background:
105
+ radial-gradient(ellipse 540px 360px at 50% 38%, rgba(99,102,241,0.16), transparent 70%),
106
+ #0a0a0b;
107
+ color: #ededee;
108
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
109
+ Roboto, Helvetica, Arial, sans-serif;
110
+ -webkit-font-smoothing: antialiased;
111
+ }
112
+ .card {
113
+ position: relative;
114
+ width: 100%;
115
+ max-width: 380px;
116
+ border: 1px solid rgba(255,255,255,0.10);
117
+ border-radius: 12px;
118
+ background: rgba(20,20,22,0.72);
119
+ backdrop-filter: blur(6px);
120
+ box-shadow: 0 1px 0 rgba(255,255,255,0.04) inset,
121
+ 0 28px 70px -32px rgba(0,0,0,0.6);
122
+ padding: 40px 28px 32px;
123
+ text-align: center;
124
+ }
125
+ .eyebrow {
126
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
127
+ font-size: 10px;
128
+ letter-spacing: 0.22em;
129
+ text-transform: uppercase;
130
+ color: rgba(237,237,238,0.55);
131
+ margin-bottom: 22px;
132
+ }
133
+ .badge {
134
+ width: 48px; height: 48px;
135
+ margin: 0 auto 20px;
136
+ display: grid; place-items: center;
137
+ border-radius: 999px;
138
+ border: 1px solid ${accent}55;
139
+ background: ${accent}1a;
140
+ color: ${accent};
141
+ font-size: 22px; font-weight: 600;
142
+ line-height: 1;
143
+ }
144
+ h1 {
145
+ margin: 0 0 8px;
146
+ font-size: 1.5rem;
147
+ letter-spacing: -0.01em;
148
+ font-weight: 600;
149
+ }
150
+ p { margin: 0; color: rgba(237,237,238,0.62); font-size: 0.9rem; line-height: 1.5; }
151
+ .footer {
152
+ margin-top: 24px;
153
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
154
+ font-size: 10px;
155
+ letter-spacing: 0.18em;
156
+ text-transform: uppercase;
157
+ color: rgba(237,237,238,0.4);
158
+ }
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <div class="card">
163
+ <div class="eyebrow">ZeNorm CLI</div>
164
+ <div class="badge">${glyph}</div>
165
+ <h1>${heading}</h1>
166
+ <p>${body}</p>
167
+ <div class="footer">Spec authoring, traced end-to-end</div>
168
+ </div>
169
+ </body>
170
+ </html>`;
171
+ }
45
172
  /**
46
173
  * Start a localhost HTTP server and wait for the OAuth callback.
47
174
  * Resolves with the authorization code once the redirect arrives.
@@ -60,21 +187,26 @@ function waitForCallback(port, expectedState) {
60
187
  const error = url.searchParams.get("error");
61
188
  if (error) {
62
189
  const desc = url.searchParams.get("error_description") ?? error;
63
- res.writeHead(200, { "Content-Type": "text/html" });
64
- res.end(`<html><body><h2>Login failed</h2><p>${desc}</p><p>You can close this tab.</p></body></html>`);
190
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
191
+ res.end(renderCallbackPage("Login failed", `${escapeHtml(desc)}. You can close this tab and try again.`));
65
192
  server.close();
66
193
  reject(new Error(`Authorization failed: ${desc}`));
67
194
  return;
68
195
  }
69
196
  if (!code || state !== expectedState) {
70
- res.writeHead(400, { "Content-Type": "text/html" });
71
- res.end(`<html><body><h2>Invalid callback</h2><p>Missing code or state mismatch.</p></body></html>`);
197
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
198
+ res.end(renderCallbackPage("Invalid callback", "Missing code or state mismatch. You can close this tab and try again."));
72
199
  server.close();
73
200
  reject(new Error("Invalid callback: missing code or state mismatch"));
74
201
  return;
75
202
  }
76
- res.writeHead(200, { "Content-Type": "text/html" });
77
- res.end(`<html><body><h2>Login successful!</h2><p>You can close this tab and return to your terminal.</p></body></html>`);
203
+ // No standalone success page — send the browser straight into the app
204
+ // (the user is already signed in), so the tab lands on the real product
205
+ // instead of a "you can close this tab" interstitial. The CLI reports
206
+ // success in the terminal. Errors still render the branded page below,
207
+ // since those can't sensibly drop the user into the dashboard.
208
+ res.writeHead(302, { Location: `${getWebUrl()}/dashboard` });
209
+ res.end();
78
210
  server.close();
79
211
  resolve(code);
80
212
  });
@@ -126,20 +258,10 @@ export async function runDeviceAuthFlow() {
126
258
  const codeVerifier = generateCodeVerifier();
127
259
  const codeChallenge = generateCodeChallenge(codeVerifier);
128
260
  const state = generateState();
129
- // Bind to port 0 to let the OS assign a free port
130
- const port = await new Promise((resolve, reject) => {
131
- const srv = createServer();
132
- srv.listen(0, () => {
133
- const addr = srv.address();
134
- if (!addr || typeof addr === "string") {
135
- srv.close();
136
- reject(new Error("Failed to get server address"));
137
- return;
138
- }
139
- const p = addr.port;
140
- srv.close(() => resolve(p));
141
- });
142
- });
261
+ // Clerk enforces exact redirect_uri matching, so the callback must land on one
262
+ // of the OAuth app's pre-registered fixed ports — a random OS-assigned port
263
+ // would never match. Pick the first registered port that is currently free.
264
+ const port = await pickFreeCallbackPort();
143
265
  const redirectUri = `http://localhost:${port}/callback`;
144
266
  const authUrl = new URL(`${getClerkIssuer()}/oauth/authorize`);
145
267
  authUrl.searchParams.set("response_type", "code");
@@ -1 +1 @@
1
- {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"AAOA,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuChE"}
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"AAMA,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuChE"}
@@ -2,7 +2,6 @@ import { parseArgs } from "node:util";
2
2
  import { runDeviceAuthFlow } from "../auth/device-flow.js";
3
3
  import { saveCredentials } from "../auth/store.js";
4
4
  import { ZenormClient } from "../api/client.js";
5
- import { AuthError } from "../util/errors.js";
6
5
  import { log } from "../util/logger.js";
7
6
  export async function loginCommand(argv) {
8
7
  const { values } = parseArgs({
@@ -39,15 +38,20 @@ export async function loginCommand(argv) {
39
38
  }
40
39
  }
41
40
  async function tryLocalDevelopmentLogin() {
41
+ const client = new ZenormClient();
42
+ // Only localhost targets use the dev-bypass header path. For hosted/prod APIs
43
+ // we must always run the real OAuth flow — never silently report a successful
44
+ // "local development mode" login against production.
45
+ if (!client.isLocalDevTarget()) {
46
+ return null;
47
+ }
42
48
  try {
43
- const client = new ZenormClient();
44
49
  const ctx = await client.getAuthContext();
45
50
  return { userId: ctx.userId, orgId: ctx.orgId };
46
51
  }
47
- catch (err) {
48
- if (err instanceof AuthError) {
49
- return null;
50
- }
52
+ catch {
53
+ // Local API unreachable or rejected the dev headers — fall through to the
54
+ // browser OAuth flow rather than masking the failure.
51
55
  return null;
52
56
  }
53
57
  }
@@ -1,4 +1,6 @@
1
1
  export declare const PRODUCTION_API_URL = "https://api.zenorm.com";
2
2
  export declare const PRODUCTION_CLERK_ISSUER = "https://clerk.zenorm.com";
3
- export declare const PRODUCTION_CLERK_CLIENT_ID = "pk_live_Y2xlcmsuemVub3JtLmNvbSQ";
3
+ export declare const PRODUCTION_WEB_URL = "https://zenorm.com";
4
+ export declare const PRODUCTION_CLERK_CLIENT_ID = "nUFCpxIFWJ4YSB3A";
5
+ export declare const CLI_CALLBACK_PORTS: readonly [4571, 4572, 4573];
4
6
  //# sourceMappingURL=defaults.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,2BAA2B,CAAC;AAC3D,eAAO,MAAM,uBAAuB,6BAA6B,CAAC;AAClE,eAAO,MAAM,0BAA0B,oCAAoC,CAAC"}
1
+ {"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,2BAA2B,CAAC;AAC3D,eAAO,MAAM,uBAAuB,6BAA6B,CAAC;AAIlE,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAWvD,eAAO,MAAM,0BAA0B,qBAAqB,CAAC;AAK7D,eAAO,MAAM,kBAAkB,6BAA8B,CAAC"}
@@ -1,3 +1,21 @@
1
1
  export const PRODUCTION_API_URL = "https://api.zenorm.com";
2
2
  export const PRODUCTION_CLERK_ISSUER = "https://clerk.zenorm.com";
3
- export const PRODUCTION_CLERK_CLIENT_ID = "pk_live_Y2xlcmsuemVub3JtLmNvbSQ";
3
+ // Prod web app. After a successful login the localhost callback 302-redirects
4
+ // the browser here so the tab lands on the real product (already signed in)
5
+ // instead of a standalone "you can close this tab" page.
6
+ export const PRODUCTION_WEB_URL = "https://zenorm.com";
7
+ // Clerk OAuth Application client id for the public CLI (prod instance
8
+ // clerk.zenorm.com). NOT the publishable key — this is the OAuth 2.0 client id
9
+ // from the "ZeNorm CLI" OAuth app. Its registered redirect_uris are the fixed
10
+ // localhost callback ports in CLI_CALLBACK_PORTS below.
11
+ //
12
+ // NOTE: this OAuth app has `consent_screen_enabled` set to FALSE out-of-band on
13
+ // the Clerk instance (via the Clerk Backend API, not tracked in this repo).
14
+ // That is what lets an already-signed-in `zenorm login` skip Clerk's OAuth
15
+ // consent screen and redirect straight to the localhost callback. To restore
16
+ // the consent screen, PATCH the OAuth app back to `consent_screen_enabled:true`.
17
+ export const PRODUCTION_CLERK_CLIENT_ID = "nUFCpxIFWJ4YSB3A";
18
+ // Fixed localhost callback ports the device-auth flow binds, in order. Clerk
19
+ // enforces exact redirect_uri matching, so these MUST stay in sync with the
20
+ // OAuth app's pre-registered redirect_uris (http://localhost:<port>/callback).
21
+ export const CLI_CALLBACK_PORTS = [4571, 4572, 4573];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ze-norm/cli",
3
3
  "private": false,
4
- "version": "0.3.0",
4
+ "version": "0.5.0",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",
7
7
  "repository": {