@pablozaiden/webapp 0.2.0 → 0.2.1

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 CHANGED
@@ -21,6 +21,7 @@ Both examples run with Bun native hot reload through `bun --hot`, with no standa
21
21
  | `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, SQLite store |
22
22
  | `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, sidebar types, UI controls, realtime hooks |
23
23
  | `@pablozaiden/webapp/contracts` | Shared auth/config/device/API-key types |
24
+ | `@pablozaiden/webapp/cli` | One-binary command helpers, device-auth credentials and generic API CLI caller |
24
25
  | `@pablozaiden/webapp/build` | Bun single-binary compile helper |
25
26
 
26
27
  See `docs/getting-started.md` for the minimum app shape and `examples/notes-todo` for a realistic app. Use `docs/github-actions.md` when adding CI, Docker and release workflows to an app built with the framework. Use `bun run screenshots` for reproducible manual visual captures. Release/publishing details for this package are in `docs/release.md`.
package/docs/cli.md ADDED
@@ -0,0 +1,45 @@
1
+ # CLI helpers
2
+
3
+ Apps built with the framework should be one app and one binary. The binary can expose subcommands such as `serve`, `version`, `update`, app-specific commands, and optional framework-backed commands like `auth`, `status`, `api` and `schema`.
4
+
5
+ Use `@pablozaiden/webapp/cli` for small composable helpers, not a mandatory CLI framework:
6
+
7
+ ```ts
8
+ import { dispatchCliCommand, printCliResult } from "@pablozaiden/webapp/cli";
9
+
10
+ const result = await dispatchCliCommand({
11
+ args: Bun.argv.slice(2),
12
+ help: "Usage: my-app <serve|version|notify>",
13
+ defaultCommand: "serve",
14
+ commands: {
15
+ serve: async () => {
16
+ app.start();
17
+ await new Promise(() => undefined);
18
+ return { exitCode: 0 };
19
+ },
20
+ version: () => ({ exitCode: 0, output: version }),
21
+ notify: (args) => runNotifyCommand(args),
22
+ },
23
+ });
24
+
25
+ process.exitCode = printCliResult(result);
26
+ ```
27
+
28
+ Device-auth CLI helpers can be composed into apps that need authenticated CLI calls. Public-token commands, such as webhook notification commands, can stay app-owned and do not need framework auth.
29
+
30
+ Route metadata can power generic API commands:
31
+
32
+ ```ts
33
+ import { runApiCliCommand } from "@pablozaiden/webapp/cli";
34
+ import { createRouteCatalog } from "@pablozaiden/webapp/server";
35
+
36
+ const catalog = createRouteCatalog(routes);
37
+ const result = await runApiCliCommand({
38
+ catalog,
39
+ args,
40
+ baseUrl: "http://localhost:3000",
41
+ credentials,
42
+ });
43
+ ```
44
+
45
+ `runApiCliCommand` can list endpoints, print schema metadata, call endpoints with `--method` and `--payload`, attach bearer credentials, and refresh once on `401`.
@@ -22,8 +22,11 @@ Run the binary with the same CLI contract:
22
22
  ```bash
23
23
  MY_APP_PORT=3300 ./dist/my-app serve
24
24
  ./dist/my-app version
25
+ ./dist/my-app api items
25
26
  ```
26
27
 
28
+ Keep server and CLI modes in the same binary. App-specific commands and framework-backed commands should be subcommands of that binary rather than separate executables.
29
+
27
30
  ## Docker
28
31
 
29
32
  Examples include Dockerfiles that copy a Bun-compiled Linux binary into a runtime image. Build the binary for the container architecture first, then build the image:
@@ -11,6 +11,9 @@ const items: Array<{ id: string; userId: string; title: string }> = [];
11
11
  const routes = defineRoutes({
12
12
  "/api/items": {
13
13
  auth: "user",
14
+ description: "List or create items.",
15
+ cliPath: "items",
16
+ tags: ["items"],
14
17
  GET: (_req, ctx) => jsonResponse(ctx.filterOwned(items)),
15
18
  async POST(req, ctx) {
16
19
  const user = ctx.requireUser();
@@ -40,6 +43,17 @@ const app = createWebAppServer({
40
43
  await app.runFromCli();
41
44
  ```
42
45
 
46
+ Apps should stay one app and one binary. Use subcommands for different modes:
47
+
48
+ ```bash
49
+ my-app serve
50
+ my-app version
51
+ my-app api items
52
+ my-app notify --message "optional app-owned command"
53
+ ```
54
+
55
+ See `docs/cli.md` for framework CLI helpers and generic API command support.
56
+
43
57
  Frontend entrypoints should use `renderWebApp` so Bun/browser hot reload reuses the existing React root instead of calling `createRoot()` twice. Import the framework CSS explicitly so Bun hot reload observes style changes:
44
58
 
45
59
  ```tsx
package/docs/server.md CHANGED
@@ -6,6 +6,9 @@ Use `createWebAppServer` with `defineRoutes`. Route patterns support exact path
6
6
  const routes = defineRoutes<AppEvent>({
7
7
  "/api/projects": {
8
8
  auth: "user",
9
+ description: "List or create projects.",
10
+ cliPath: "projects",
11
+ tags: ["projects"],
9
12
  GET: (_req, ctx) => {
10
13
  return jsonResponse(ctx.filterOwned(projects));
11
14
  },
@@ -45,6 +48,18 @@ Route defaults are intentionally secure:
45
48
 
46
49
  Set `auth: "public", sameOrigin: "never"` only for deliberate unauthenticated endpoints such as health probes, webhooks or callback receivers.
47
50
 
51
+ Route definitions can include optional metadata. This keeps the API route table as the single source of truth for handlers, CLI endpoint listing, schema output and docs:
52
+
53
+ | Field | Meaning |
54
+ | --- | --- |
55
+ | `description` | Human-readable route description |
56
+ | `cliPath` | CLI-friendly path; defaults to the API path without `/api/` |
57
+ | `tags` | Grouping labels for docs/CLI |
58
+ | `requestSchema`, `querySchema`, `responseSchema` | Optional schema objects for CLI/docs |
59
+ | `catalog: false` | Exclude a route from generated catalogs |
60
+
61
+ Use `createRouteCatalog(routes)` and `findRouteCatalogEntry(catalog, input)` to power app CLI commands without maintaining a second route catalog.
62
+
48
63
  Prefer explicit `auth: "user"`, `auth: "admin"` or `auth: "owner"` on app routes. They enforce the role before the handler runs, including API-key and device bearer requests.
49
64
 
50
65
  Route context is user-aware:
@@ -78,3 +93,38 @@ Built-in endpoints include:
78
93
  | `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
79
94
  | `/api/server/kill` | Authenticated server shutdown |
80
95
  | `/api/ws` | Realtime websocket by default |
96
+
97
+ ## Public/static routes
98
+
99
+ Declare public non-API assets explicitly with `publicRoutes`:
100
+
101
+ ```ts
102
+ createWebAppServer({
103
+ // ...
104
+ publicRoutes: {
105
+ "/manifest.webmanifest": {
106
+ headers: { "content-type": "application/manifest+json" },
107
+ GET: manifestJson,
108
+ },
109
+ "/service-worker": serviceWorker,
110
+ },
111
+ });
112
+ ```
113
+
114
+ Only declared public routes are served this way. Unknown `/api/*` paths still return `404`, while normal frontend paths still return the React index.
115
+
116
+ ## App-owned websocket upgrades
117
+
118
+ Normal app state should use framework realtime. For raw transports such as terminals, VNC, or port-forward proxies, route handlers may call `ctx.server?.upgrade(...)` and return `undefined`:
119
+
120
+ ```ts
121
+ "/api/terminal": {
122
+ auth: "user",
123
+ sameOrigin: "always",
124
+ GET: (req, ctx) => ctx.server?.upgrade(req, {
125
+ data: { webappSocketHandler: "terminal", sessionId: ctx.params.id },
126
+ }) ? undefined : new Response("Upgrade failed", { status: 400 }),
127
+ }
128
+ ```
129
+
130
+ Register matching handlers with `websockets`. Framework auth and same-origin checks run before the upgrade route handler.
package/docs/settings.md CHANGED
@@ -11,6 +11,10 @@ Settings is framework-owned so apps stay consistent. It includes:
11
11
  - Admin server kill
12
12
  - Version/about
13
13
 
14
+ Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.
15
+
16
+ The server kill control follows the normal Settings row layout: explanation on the left, action on the right. After confirmation and a successful response, Settings shows a 15-second shutdown countdown progress bar that visibly drains before reloading the page.
17
+
14
18
  Apps can append structured custom sections:
15
19
 
16
20
  ```tsx
@@ -54,6 +54,8 @@ Generated screenshots live in `artifacts/screenshots`:
54
54
 
55
55
  Use `bun run screenshots` to regenerate these captures with Playwright. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths.
56
56
 
57
- Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles.
57
+ Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles. When screenshots are captured to validate a visual change, review them against the specific goal; capturing files without checking the result is not validation.
58
+
59
+ All destructive delete actions must use the framework `ConfirmDialog` before calling the delete endpoint. Server lifecycle actions such as kill/reboot must also require confirmation and show the standard 15-second shutdown countdown progress bar after the request succeeds.
58
60
 
59
61
  Prefer the framework action-menu pattern for app commands. Actions such as New task, New note, New project, archive, delete, or state transitions should live in `SidebarNode.actions` and/or `WebAppRoot.header.getActions` so they appear behind the three-line title-bar/item action menus. Use discrete buttons mainly for form submission or inline controls that cannot reasonably live in an action menu.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "./web": "./src/web/index.ts",
27
27
  "./web/styles.css": "./src/web/styles.css",
28
28
  "./contracts": "./src/contracts/index.ts",
29
+ "./cli": "./src/cli/index.ts",
29
30
  "./build": "./src/build/index.ts"
30
31
  },
31
32
  "scripts": {
@@ -0,0 +1,119 @@
1
+ import { z } from "zod";
2
+ import { findRouteCatalogEntry, type RouteCatalogEntry } from "../server/route-catalog";
3
+ import { getAuthorizedHeaders, refreshDeviceCredentials, type StoredDeviceCredentials } from "./device-auth";
4
+ import { readOption, type CliCommandResult } from "./runtime";
5
+
6
+ export interface ApiCliCredentialsStore {
7
+ read(): Promise<StoredDeviceCredentials | undefined>;
8
+ write(value: StoredDeviceCredentials): Promise<void>;
9
+ }
10
+
11
+ export interface ApiCliCommandOptions {
12
+ catalog: readonly RouteCatalogEntry[];
13
+ args: string[];
14
+ mode?: "api" | "schema";
15
+ baseUrl?: string;
16
+ credentials?: ApiCliCredentialsStore;
17
+ fetchFn?: typeof fetch;
18
+ now?: () => Date;
19
+ }
20
+
21
+ function schemaJson(schema: unknown): unknown {
22
+ if (!schema) return undefined;
23
+ try {
24
+ return z.toJSONSchema(schema as z.ZodTypeAny);
25
+ } catch {
26
+ return schema;
27
+ }
28
+ }
29
+
30
+ function listOutput(catalog: readonly RouteCatalogEntry[]): string {
31
+ return catalog
32
+ .filter((entry) => entry.path.startsWith("/api/"))
33
+ .map((entry) => `${entry.methods.join(", ")} ${entry.cliPath}${entry.description ? ` - ${entry.description}` : ""}`)
34
+ .join("\n");
35
+ }
36
+
37
+ function schemaOutput(entry: RouteCatalogEntry): string {
38
+ return JSON.stringify({
39
+ path: entry.path,
40
+ cliPath: entry.cliPath,
41
+ methods: entry.methods,
42
+ auth: entry.auth,
43
+ sameOrigin: entry.sameOrigin,
44
+ scopes: entry.scopes,
45
+ description: entry.description,
46
+ tags: entry.tags,
47
+ querySchema: schemaJson(entry.querySchema),
48
+ requestSchema: schemaJson(entry.requestSchema),
49
+ responseSchema: schemaJson(entry.responseSchema),
50
+ }, null, 2);
51
+ }
52
+
53
+ function endpointArg(args: string[]): string | undefined {
54
+ return args.find((arg) => !arg.startsWith("--"));
55
+ }
56
+
57
+ async function authHeaders(input: ApiCliCommandOptions): Promise<Headers> {
58
+ const headers = new Headers({ accept: "application/json" });
59
+ const stored = await input.credentials?.read();
60
+ if (!stored) return headers;
61
+ const refreshed = await refreshDeviceCredentials({
62
+ credentials: stored,
63
+ store: input.credentials,
64
+ fetchFn: input.fetchFn,
65
+ now: input.now,
66
+ });
67
+ return refreshed ? getAuthorizedHeaders(refreshed, headers) : headers;
68
+ }
69
+
70
+ export async function runApiCliCommand(input: ApiCliCommandOptions): Promise<CliCommandResult> {
71
+ const endpoint = endpointArg(input.args);
72
+ if (!endpoint) {
73
+ return { exitCode: 0, output: listOutput(input.catalog) };
74
+ }
75
+ const match = findRouteCatalogEntry(input.catalog, endpoint);
76
+ if (!match) {
77
+ return { exitCode: 1, error: `Unknown API endpoint: ${endpoint}` };
78
+ }
79
+ if (input.mode === "schema") {
80
+ return { exitCode: 0, output: schemaOutput(match.entry) };
81
+ }
82
+ const method = (readOption(input.args, ["--method", "-X"]) ?? match.entry.methods[0] ?? "GET").toUpperCase();
83
+ if (!match.entry.methods.includes(method as never)) {
84
+ return { exitCode: 1, error: `Method ${method} is not available for ${match.entry.cliPath}` };
85
+ }
86
+ const payload = readOption(input.args, ["--payload", "--data", "-d"]);
87
+ const baseUrl = (input.baseUrl ?? "http://localhost:3000").replace(/\/+$/, "");
88
+ const url = new URL(`${baseUrl}${match.path}`);
89
+ const headers = await authHeaders(input);
90
+ let body: string | undefined;
91
+ if (payload !== undefined) {
92
+ JSON.parse(payload) as unknown;
93
+ body = payload;
94
+ headers.set("content-type", "application/json");
95
+ }
96
+ const send = () => (input.fetchFn ?? fetch)(url, { method, headers, body });
97
+ let response = await send();
98
+ if (response.status === 401 && input.credentials) {
99
+ const stored = await input.credentials.read();
100
+ if (stored) {
101
+ const refreshed = await refreshDeviceCredentials({ credentials: { ...stored, accessTokenExpiresAt: new Date(0).toISOString() }, store: input.credentials, fetchFn: input.fetchFn, now: input.now });
102
+ if (refreshed) {
103
+ headers.set("authorization", `${refreshed.tokenType} ${refreshed.accessToken}`);
104
+ }
105
+ response = await send();
106
+ }
107
+ }
108
+ const text = await response.text();
109
+ let parsed: unknown = text;
110
+ try {
111
+ parsed = text ? JSON.parse(text) as unknown : null;
112
+ } catch {
113
+ parsed = text || null;
114
+ }
115
+ return {
116
+ exitCode: response.ok ? 0 : 1,
117
+ output: JSON.stringify({ status: { code: response.status, ok: response.ok, text: response.statusText }, response: parsed }, null, 2),
118
+ };
119
+ }
@@ -0,0 +1,67 @@
1
+ import { chmodSync } from "node:fs";
2
+ import { mkdir, readFile, rename, rm } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export interface JsonFileStore<T> {
6
+ path(): string;
7
+ read(): Promise<T | undefined>;
8
+ write(value: T): Promise<void>;
9
+ clear(): Promise<void>;
10
+ }
11
+
12
+ function chmodIfPossible(path: string, mode: number): void {
13
+ try {
14
+ chmodSync(path, mode);
15
+ } catch {
16
+ // Not all platforms/filesystems support POSIX modes.
17
+ }
18
+ }
19
+
20
+ export function createJsonFileStore<T>(input: {
21
+ appDirectoryName: string;
22
+ fileName: string;
23
+ envHome?: string;
24
+ parse(value: unknown): T;
25
+ home?: string;
26
+ }): JsonFileStore<T> {
27
+ const stateDir = () => {
28
+ const explicit = input.envHome ? process.env[input.envHome]?.trim() : undefined;
29
+ const home = input.home ?? process.env["HOME"]?.trim();
30
+ if (explicit) return explicit;
31
+ if (!home) throw new Error("HOME is not set");
32
+ return join(home, input.appDirectoryName);
33
+ };
34
+ const filePath = () => join(stateDir(), input.fileName);
35
+ return {
36
+ path: filePath,
37
+ async read() {
38
+ try {
39
+ return input.parse(JSON.parse(await readFile(filePath(), "utf8")) as unknown);
40
+ } catch (error) {
41
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
42
+ return undefined;
43
+ }
44
+ throw error;
45
+ }
46
+ },
47
+ async write(value) {
48
+ const target = filePath();
49
+ const dir = dirname(target);
50
+ await mkdir(dir, { recursive: true, mode: 0o700 });
51
+ chmodIfPossible(dir, 0o700);
52
+ const temp = join(dir, `.${input.fileName}.${process.pid}.${crypto.randomUUID()}.tmp`);
53
+ try {
54
+ await Bun.write(temp, `${JSON.stringify(value, null, 2)}\n`);
55
+ chmodIfPossible(temp, 0o600);
56
+ await rename(temp, target);
57
+ chmodIfPossible(target, 0o600);
58
+ } catch (error) {
59
+ await rm(temp, { force: true });
60
+ throw error;
61
+ }
62
+ },
63
+ async clear() {
64
+ await rm(filePath(), { force: true });
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,179 @@
1
+ import { createJsonFileStore, type JsonFileStore } from "./credentials";
2
+
3
+ export interface StoredDeviceCredentials {
4
+ baseUrl: string;
5
+ clientId: string;
6
+ accessToken: string;
7
+ refreshToken: string;
8
+ tokenType: "Bearer";
9
+ scope: string;
10
+ accessTokenExpiresAt: string;
11
+ createdAt: string;
12
+ updatedAt: string;
13
+ }
14
+
15
+ export function normalizeBaseUrl(value: string): string {
16
+ const url = new URL(value.trim());
17
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
18
+ throw new Error(`Invalid base URL protocol: ${url.protocol}`);
19
+ }
20
+ return url.toString().replace(/\/+$/, "");
21
+ }
22
+
23
+ export function parseStoredDeviceCredentials(value: unknown): StoredDeviceCredentials {
24
+ if (!value || typeof value !== "object") {
25
+ throw new Error("Stored credentials must be an object");
26
+ }
27
+ const record = value as Record<string, unknown>;
28
+ const credentials = {
29
+ baseUrl: String(record["baseUrl"] ?? ""),
30
+ clientId: String(record["clientId"] ?? ""),
31
+ accessToken: String(record["accessToken"] ?? ""),
32
+ refreshToken: String(record["refreshToken"] ?? ""),
33
+ tokenType: record["tokenType"],
34
+ scope: String(record["scope"] ?? ""),
35
+ accessTokenExpiresAt: String(record["accessTokenExpiresAt"] ?? ""),
36
+ createdAt: String(record["createdAt"] ?? ""),
37
+ updatedAt: String(record["updatedAt"] ?? ""),
38
+ };
39
+ if (!credentials.baseUrl || !credentials.clientId || !credentials.accessToken || !credentials.refreshToken || credentials.tokenType !== "Bearer") {
40
+ throw new Error("Stored credentials are invalid");
41
+ }
42
+ return credentials as StoredDeviceCredentials;
43
+ }
44
+
45
+ export function createDeviceCredentialsStore(input: {
46
+ appDirectoryName: string;
47
+ envHome?: string;
48
+ fileName?: string;
49
+ home?: string;
50
+ }): JsonFileStore<StoredDeviceCredentials> {
51
+ return createJsonFileStore({
52
+ appDirectoryName: input.appDirectoryName,
53
+ fileName: input.fileName ?? "device-auth.json",
54
+ envHome: input.envHome,
55
+ home: input.home,
56
+ parse: parseStoredDeviceCredentials,
57
+ });
58
+ }
59
+
60
+ export function getAuthorizedHeaders(credentials: StoredDeviceCredentials, headers?: HeadersInit): Headers {
61
+ const result = new Headers(headers);
62
+ result.set("authorization", `${credentials.tokenType} ${credentials.accessToken}`);
63
+ return result;
64
+ }
65
+
66
+ function isExpired(credentials: StoredDeviceCredentials, now: Date): boolean {
67
+ return new Date(credentials.accessTokenExpiresAt).getTime() <= now.getTime();
68
+ }
69
+
70
+ async function requestJson(fetchFn: typeof fetch, url: string, init?: RequestInit): Promise<{ response: Response; body: unknown }> {
71
+ const headers = new Headers(init?.headers);
72
+ headers.set("accept", "application/json");
73
+ const response = await fetchFn(url, { ...init, headers });
74
+ const text = await response.text();
75
+ if (!text) {
76
+ return { response, body: undefined };
77
+ }
78
+ try {
79
+ return { response, body: JSON.parse(text) as unknown };
80
+ } catch {
81
+ return { response, body: text };
82
+ }
83
+ }
84
+
85
+ function tokenError(body: unknown, status: number): string {
86
+ if (body && typeof body === "object") {
87
+ const record = body as Record<string, unknown>;
88
+ return String(record["error_description"] ?? record["message"] ?? record["error"] ?? `Request failed with status ${status}`);
89
+ }
90
+ return `Request failed with status ${status}`;
91
+ }
92
+
93
+ function tokenCredentials(baseUrl: string, clientId: string, tokenSet: Record<string, unknown>, now: Date): StoredDeviceCredentials {
94
+ const expiresIn = Number(tokenSet["expires_in"] ?? 0);
95
+ return {
96
+ baseUrl,
97
+ clientId,
98
+ accessToken: String(tokenSet["access_token"] ?? ""),
99
+ refreshToken: String(tokenSet["refresh_token"] ?? ""),
100
+ tokenType: "Bearer",
101
+ scope: String(tokenSet["scope"] ?? ""),
102
+ accessTokenExpiresAt: new Date(now.getTime() + expiresIn * 1000).toISOString(),
103
+ createdAt: now.toISOString(),
104
+ updatedAt: now.toISOString(),
105
+ };
106
+ }
107
+
108
+ export async function refreshDeviceCredentials(input: {
109
+ credentials: StoredDeviceCredentials;
110
+ store?: { write(value: StoredDeviceCredentials): Promise<void> };
111
+ fetchFn?: typeof fetch;
112
+ now?: () => Date;
113
+ }): Promise<StoredDeviceCredentials | undefined> {
114
+ if (!isExpired(input.credentials, (input.now ?? (() => new Date()))())) {
115
+ return input.credentials;
116
+ }
117
+ const now = input.now?.() ?? new Date();
118
+ const { response, body } = await requestJson(input.fetchFn ?? fetch, `${input.credentials.baseUrl}/api/auth/token`, {
119
+ method: "POST",
120
+ headers: { "content-type": "application/json" },
121
+ body: JSON.stringify({
122
+ grant_type: "refresh_token",
123
+ refresh_token: input.credentials.refreshToken,
124
+ client_id: input.credentials.clientId,
125
+ }),
126
+ });
127
+ if (!response.ok) {
128
+ return undefined;
129
+ }
130
+ const next = tokenCredentials(input.credentials.baseUrl, input.credentials.clientId, body as Record<string, unknown>, now);
131
+ await input.store?.write(next);
132
+ return next;
133
+ }
134
+
135
+ export async function runDeviceAuthCommand(input: {
136
+ baseUrl: string;
137
+ clientId: string;
138
+ store: JsonFileStore<StoredDeviceCredentials>;
139
+ scope?: string;
140
+ fetchFn?: typeof fetch;
141
+ sleep?: (ms: number) => Promise<void>;
142
+ now?: () => Date;
143
+ out?: (message: string) => void;
144
+ }): Promise<number> {
145
+ const baseUrl = normalizeBaseUrl(input.baseUrl);
146
+ const fetchFn = input.fetchFn ?? fetch;
147
+ const now = input.now ?? (() => new Date());
148
+ const out = input.out ?? console.log;
149
+ const start = await requestJson(fetchFn, `${baseUrl}/api/auth/device`, {
150
+ method: "POST",
151
+ headers: { "content-type": "application/json" },
152
+ body: JSON.stringify({ clientId: input.clientId, scope: input.scope }),
153
+ });
154
+ if (!start.response.ok) throw new Error(tokenError(start.body, start.response.status));
155
+ const started = start.body as Record<string, unknown>;
156
+ out(`Open: ${String(started["verification_uri_complete"] ?? started["verification_uri"])}`);
157
+ out(`Code: ${String(started["user_code"] ?? "")}`);
158
+ const interval = Number(started["interval"] ?? 5) * 1000;
159
+ while (true) {
160
+ await (input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(interval);
161
+ const token = await requestJson(fetchFn, `${baseUrl}/api/auth/token`, {
162
+ method: "POST",
163
+ headers: { "content-type": "application/json" },
164
+ body: JSON.stringify({
165
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
166
+ device_code: started["device_code"],
167
+ client_id: input.clientId,
168
+ }),
169
+ });
170
+ if (token.response.ok) {
171
+ await input.store.write(tokenCredentials(baseUrl, input.clientId, token.body as Record<string, unknown>, now()));
172
+ out(`Authenticated with ${baseUrl}`);
173
+ return 0;
174
+ }
175
+ const error = (token.body as Record<string, unknown> | undefined)?.["error"];
176
+ if (error === "authorization_pending" || error === "slow_down") continue;
177
+ throw new Error(tokenError(token.body, token.response.status));
178
+ }
179
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./runtime";
2
+ export * from "./credentials";
3
+ export * from "./device-auth";
4
+ export * from "./api-command";
@@ -0,0 +1,51 @@
1
+ export interface CliCommandResult {
2
+ exitCode: number;
3
+ output?: string;
4
+ error?: string;
5
+ }
6
+
7
+ export type CliCommandHandler = (args: string[]) => CliCommandResult | Promise<CliCommandResult>;
8
+
9
+ export function printCliResult(result: CliCommandResult, output: Pick<Console, "log" | "error"> = console): number {
10
+ if (result.output) {
11
+ output.log(result.output);
12
+ }
13
+ if (result.error) {
14
+ output.error(result.error);
15
+ }
16
+ return result.exitCode;
17
+ }
18
+
19
+ export function readOption(args: readonly string[], names: readonly string[]): string | undefined {
20
+ for (let index = 0; index < args.length; index += 1) {
21
+ const arg = args[index];
22
+ if (!arg) continue;
23
+ for (const name of names) {
24
+ if (arg === name) return args[index + 1];
25
+ if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1);
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ export function hasFlag(args: readonly string[], names: readonly string[]): boolean {
32
+ return args.some((arg) => names.includes(arg));
33
+ }
34
+
35
+ export async function dispatchCliCommand(input: {
36
+ args: string[];
37
+ commands: Record<string, CliCommandHandler>;
38
+ defaultCommand?: string;
39
+ help: string;
40
+ }): Promise<CliCommandResult> {
41
+ const [rawCommand, ...rest] = input.args;
42
+ const command = rawCommand && !rawCommand.startsWith("-") ? rawCommand : input.defaultCommand;
43
+ if (!command || rawCommand === "help" || rawCommand === "--help" || rawCommand === "-h") {
44
+ return { exitCode: rawCommand ? 0 : 1, output: input.help };
45
+ }
46
+ const handler = input.commands[command];
47
+ if (!handler) {
48
+ return { exitCode: 1, error: `Unknown command: ${command}`, output: input.help };
49
+ }
50
+ return await handler(rawCommand === command ? rest : input.args);
51
+ }