@ashley-shrok/viewmodel-shell 0.4.9 → 0.6.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.
package/README.md CHANGED
@@ -33,15 +33,33 @@ If your backend is .NET: copy `demo/Tasks/AspNetCore/ViewModels.cs` from the [Gi
33
33
 
34
34
  For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
35
35
 
36
+ ## Authenticated downloads
37
+
38
+ When a header-authenticated consumer (e.g. `Authorization: Bearer <jwt>` via `ShellOptions.getRequestHeaders()`) needs to offer a file download, return a `"download"` side-effect from your action handler — the shell fetches the URL with the same headers merged in, parses `Content-Disposition` + `Content-Type`, and triggers a browser "Save As":
39
+
40
+ ```csharp
41
+ return new ShellResponse<MyState>(BuildVm(state), state)
42
+ .WithEffect(ShellSideEffect.Download("/api/invoices/42/pdf", "invoice-42.pdf"));
43
+ ```
44
+
45
+ ```typescript
46
+ return { vm: buildVm(state), state,
47
+ sideEffects: [shellSideEffect.download("/api/invoices/42/pdf", "invoice-42.pdf")] };
48
+ ```
49
+
50
+ The download endpoint stays auth-gated and the server authorizes in the action handler — no signed-URL machinery. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md#client-side-effects) for the wire format and pattern.
51
+
36
52
  ## Terminal (TUI)
37
53
 
38
- The same backend renders in a terminal — same wire, no backend change. Point the CLI at any ViewModel Shell endpoint:
54
+ The same backend renders in a terminal — same wire, no backend change, with a real lazygit-style UX: mouse clicks, wheel scroll, per-pane focus cycle, and a context-aware status bar. Point the CLI at any ViewModel Shell endpoint:
39
55
 
40
56
  ```bash
41
- npx vms-tui https://your-app.example/api/tasks
57
+ bunx vms-tui https://your-app.example/api/tasks
42
58
  ```
43
59
 
44
- The action endpoint is derived by convention (`<endpoint>/action`). Or wire it programmatically, exactly like `BrowserAdapter`:
60
+ The action endpoint is derived by convention (`<endpoint>/action`). The TUI requires the [Bun](https://bun.sh) runtime — install once via `curl -fsSL https://bun.sh/install | bash` (or any installer on bun.sh). Browser and server consumers are unaffected; only `/tui` + `vms-tui` need Bun.
61
+
62
+ Wire it programmatically, exactly like `BrowserAdapter`:
45
63
 
46
64
  ```ts
47
65
  import { ViewModelShell } from "@ashley-shrok/viewmodel-shell";
@@ -56,12 +74,14 @@ const shell = new ViewModelShell({
56
74
  shell.load();
57
75
  ```
58
76
 
59
- On an interactive terminal the app **fills the screen** via the alternate-screen buffer a vim/htop-style takeover that re-flows on resize and restores your prior terminal verbatim on exit (every exit path: quit, Ctrl-C, SIGTERM, crash). Opt out with `new TuiAdapter({ viewport: "content" })` for intrinsic content size and no screen takeover. Non-interactive runs (pipe / CI / agent / `</dev/null`) are unaffected one static frame, no alternate screen.
77
+ **Interaction model.** Every `section`, top-level `list`, and top-level `table` is its own scrollable focus pane with a border. Tab/Shift-Tab cycles focus across panes; ↑↓ PgUp/PgDn scroll inside the focused pane; click any button, checkbox, link, copy-button, table header, or table row to act on it. Enter activates the focused pane's primary actionable (first button dispatch, first link navigate, first copy-button OSC-52 copy). Space toggles the focused pane's first checkbox-with-action. When the pane has a text field, Enter submits the enclosing form (Field's `<input onSubmit>`) and Space is a normal character.
78
+
79
+ On an interactive terminal the app **fills the screen** via the alternate-screen buffer — a vim/htop-style takeover that re-flows on resize and restores your prior terminal verbatim on exit (every exit path: quit, Ctrl-C, SIGTERM, crash). Opt out with `new TuiAdapter({ viewport: "content" })` for intrinsic content size and no screen takeover. Non-interactive runs (pipe / CI / agent / `</dev/null`) are unaffected.
60
80
 
61
- The TUI is built on [Ink](https://github.com/vadimdemedes/ink) and friends, declared as **optional** dependencies (`ink`, `react`, `ink-text-input`, `ink-select-input`) so web and server consumers are unaffected — they are never imported by the browser, server, or core entrypoints. `npx vms-tui` installs them automatically. **Project consumers using `TuiAdapter` programmatically must add all four explicitly** — optional dependencies are *not* pulled transitively when another project depends on this package (notably with `bun install`):
81
+ The TUI is built on [OpenTUI](https://github.com/anomalyco/opentui), declared as **optional** dependencies (`@opentui/core`, `@opentui/react`, `react@19`) so web and server consumers are unaffected — they are never imported by the browser, server, or core entrypoints. `bunx vms-tui` installs them automatically. **Project consumers using `TuiAdapter` programmatically must add all three explicitly** — optional dependencies are *not* pulled transitively:
62
82
 
63
83
  ```bash
64
- npm i @ashley-shrok/viewmodel-shell ink react ink-text-input ink-select-input
84
+ bun add @ashley-shrok/viewmodel-shell @opentui/core @opentui/react react
65
85
  ```
66
86
 
67
87
  See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for what the terminal renders.
package/dist/browser.d.ts CHANGED
@@ -6,6 +6,10 @@ export declare class BrowserAdapter implements Adapter {
6
6
  render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
7
7
  navigate(url: string): void;
8
8
  storage(scope: "local" | "session", key: string, value: string): void;
9
+ /** Save an authenticated-download blob via the browser's native Save-As.
10
+ * contentType is informational — the Blob's own .type takes precedence in
11
+ * browsers. We accept the arg for adapter symmetry (other adapters use it). */
12
+ saveFile(data: Blob, filename: string, _contentType: string): void;
9
13
  transport(input: string, init: {
10
14
  method?: string;
11
15
  headers?: Record<string, string>;
package/dist/browser.js CHANGED
@@ -68,6 +68,26 @@ export class BrowserAdapter {
68
68
  const store = scope === "session" ? sessionStorage : localStorage;
69
69
  store.setItem(key, value);
70
70
  }
71
+ /** Save an authenticated-download blob via the browser's native Save-As.
72
+ * contentType is informational — the Blob's own .type takes precedence in
73
+ * browsers. We accept the arg for adapter symmetry (other adapters use it). */
74
+ saveFile(data, filename, _contentType) {
75
+ const url = URL.createObjectURL(data);
76
+ try {
77
+ const a = document.createElement("a");
78
+ a.href = url;
79
+ a.download = filename;
80
+ a.style.display = "none";
81
+ document.body.appendChild(a);
82
+ a.click();
83
+ a.remove();
84
+ }
85
+ finally {
86
+ // Revoke async so the browser has time to start the download. The 0ms
87
+ // setTimeout is the established pattern (Chromium/Firefox/Safari).
88
+ setTimeout(() => URL.revokeObjectURL(url), 0);
89
+ }
90
+ }
71
91
  async transport(input, init, hooks) {
72
92
  const onUploadProgress = hooks?.onUploadProgress;
73
93
  if (!onUploadProgress) {
package/dist/index.d.ts CHANGED
@@ -23,6 +23,15 @@ export interface Adapter {
23
23
  }, hooks?: {
24
24
  onUploadProgress?: (sent: number, total: number) => void;
25
25
  }): Promise<Response>;
26
+ /** Save an authenticated-download blob to platform-appropriate storage. The
27
+ * shell fetches the URL with getRequestHeaders() merged, parses
28
+ * Content-Disposition filename + Content-Type, and calls saveFile().
29
+ * No safe no-op exists — a silently-dropped authenticated download is a
30
+ * correctness/security bug (cf. navigate/storage). If a "download"
31
+ * side-effect arrives and this method is absent, the shell fails loudly.
32
+ * May return void or Promise<void>; the shell awaits the return value so
33
+ * async I/O errors surface via onError. */
34
+ saveFile?(data: Blob, filename: string, contentType: string): void | Promise<void>;
26
35
  }
27
36
  export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
28
37
  export interface PageNode {
@@ -199,10 +208,17 @@ export interface ShellOptions {
199
208
  pollInterval?: number;
200
209
  }
201
210
  export interface ShellSideEffect {
202
- /** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
211
+ /** "set-local-storage" | "set-session-storage" | "download" — unknown types are silently ignored. */
203
212
  type: string;
213
+ /** For "set-local-storage" / "set-session-storage": the storage key. */
204
214
  key?: string;
215
+ /** For "set-local-storage" / "set-session-storage": the storage value. */
205
216
  value?: string;
217
+ /** For "download": the URL to fetch (shell merges getRequestHeaders() into the request). */
218
+ url?: string;
219
+ /** For "download": optional filename hint. Response Content-Disposition wins
220
+ * when present; this is the fallback before the URL basename. */
221
+ filename?: string;
206
222
  }
207
223
  export interface ShellResponse {
208
224
  vm: ViewNode;
@@ -231,4 +247,18 @@ export declare class ViewModelShell {
231
247
  private failCapability;
232
248
  private processResponse;
233
249
  private schedulePoll;
250
+ /**
251
+ * Authenticated download: fetch the URL with getRequestHeaders() merged
252
+ * (Bearer / anti-forgery / etc.), parse the response filename + content
253
+ * type, and hand the bytes to the adapter's platform-specific save verb.
254
+ *
255
+ * Deliberately uses core `fetch`, NOT adapter.transport. The existing
256
+ * transport override (XHR for upload-progress) constructs Response from
257
+ * xhr.responseText (a string) and would corrupt binary blobs. Download
258
+ * progress is a future, opt-in extension on its own seam.
259
+ *
260
+ * Errors (missing capability / non-OK status / adapter throw) surface
261
+ * via onError; the download() call itself never throws into the caller.
262
+ */
263
+ private download;
234
264
  }
package/dist/index.js CHANGED
@@ -101,9 +101,10 @@ export class ViewModelShell {
101
101
  failCapability(capability, detail) {
102
102
  const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
103
103
  `server response requires it (${detail}). This is a hard failure, not a no-op: ` +
104
- `a silently-dropped ${capability} (e.g. an auth token never persisted, or a ` +
105
- `redirect that never happens) is a correctness/security bug. Implement ` +
106
- `${capability}() on your Adapter, or (for redirect) pass ShellOptions.onRedirect.`);
104
+ `a silently-dropped ${capability} (e.g. an auth token never persisted, a ` +
105
+ `redirect that never happens, or an authenticated download silently swallowed) ` +
106
+ `is a correctness/security bug. Implement ${capability}() on your Adapter, ` +
107
+ `or (for redirect) pass ShellOptions.onRedirect.`);
107
108
  this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
108
109
  }
109
110
  processResponse(body) {
@@ -121,6 +122,12 @@ export class ViewModelShell {
121
122
  else
122
123
  this.failCapability("storage", `side-effect "${effect.type}" key="${effect.key}"`);
123
124
  }
125
+ else if (effect.type === "download" && effect.url != null) {
126
+ // Fire-and-forget. Surfaces errors via onError. Does not block the
127
+ // render/redirect branch below — downloads are a side channel, like
128
+ // storage, and a slow download MUST NOT delay the user-visible update.
129
+ void this.download(effect.url, effect.filename);
130
+ }
124
131
  }
125
132
  if (body.redirect) {
126
133
  if (this.options.onRedirect) {
@@ -150,4 +157,93 @@ export class ViewModelShell {
150
157
  this.dispatch({ name: "poll" }, true);
151
158
  }, delay);
152
159
  }
160
+ /**
161
+ * Authenticated download: fetch the URL with getRequestHeaders() merged
162
+ * (Bearer / anti-forgery / etc.), parse the response filename + content
163
+ * type, and hand the bytes to the adapter's platform-specific save verb.
164
+ *
165
+ * Deliberately uses core `fetch`, NOT adapter.transport. The existing
166
+ * transport override (XHR for upload-progress) constructs Response from
167
+ * xhr.responseText (a string) and would corrupt binary blobs. Download
168
+ * progress is a future, opt-in extension on its own seam.
169
+ *
170
+ * Errors (missing capability / non-OK status / adapter throw) surface
171
+ * via onError; the download() call itself never throws into the caller.
172
+ */
173
+ async download(url, hintFilename) {
174
+ const adapter = this.options.adapter;
175
+ if (!adapter.saveFile) {
176
+ this.failCapability("saveFile", `download from "${url}"`);
177
+ return;
178
+ }
179
+ try {
180
+ const extraHeaders = this.options.getRequestHeaders
181
+ ? await this.options.getRequestHeaders()
182
+ : {};
183
+ const res = await fetch(url, { headers: extraHeaders });
184
+ if (!res.ok) {
185
+ throw new Error(`Download from ${url} failed: ${res.status} ${res.statusText}`);
186
+ }
187
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
188
+ const filename = parseContentDispositionFilename(res.headers.get("Content-Disposition")) ??
189
+ hintFilename ??
190
+ basenameFromUrl(url) ??
191
+ "download";
192
+ const blob = await res.blob();
193
+ await adapter.saveFile(blob, filename, contentType);
194
+ }
195
+ catch (err) {
196
+ const error = err instanceof Error ? err : new Error(String(err));
197
+ this.options.onError ? this.options.onError(error) : console.error("[ViewModelShell]", error);
198
+ }
199
+ }
200
+ }
201
+ // ─── Download helpers (file-private; no platform globals) ────────────────────
202
+ // `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
203
+ // off the check:core-globals denylist. `URL.createObjectURL` is browser-only
204
+ // and stays in BrowserAdapter behind the capability seam.
205
+ /**
206
+ * Parse a `Content-Disposition` header for a filename. RFC 5987 `filename*`
207
+ * (UTF-8, percent-encoded, internationalized) wins over the plain `filename`
208
+ * parameter when both are present — that's the spec-canonical preference.
209
+ * Returns null when the header is absent or no filename parameter is found.
210
+ */
211
+ function parseContentDispositionFilename(header) {
212
+ if (header == null)
213
+ return null;
214
+ // RFC 5987 extended form: filename*=UTF-8''<percent-encoded>
215
+ const ext = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
216
+ if (ext) {
217
+ try {
218
+ const decoded = decodeURIComponent(ext[2].trim());
219
+ if (decoded.length > 0)
220
+ return decoded;
221
+ }
222
+ catch {
223
+ // Malformed percent-encoding — fall through to plain `filename`.
224
+ }
225
+ }
226
+ // Plain form: filename="..." or filename=...
227
+ const plain = /filename\s*=\s*"([^"]*)"|filename\s*=\s*([^;]+)/i.exec(header);
228
+ if (plain) {
229
+ const val = (plain[1] ?? plain[2] ?? "").trim();
230
+ if (val.length > 0)
231
+ return val;
232
+ }
233
+ return null;
234
+ }
235
+ /**
236
+ * Extract a basename from a URL (relative or absolute). Returns null if the
237
+ * path is empty or ends with `/`. Uses a dummy base so relative URLs parse;
238
+ * the base is discarded.
239
+ */
240
+ function basenameFromUrl(url) {
241
+ try {
242
+ const u = new URL(url, "http://_/");
243
+ const last = u.pathname.split("/").pop();
244
+ return last && last.length > 0 ? decodeURIComponent(last) : null;
245
+ }
246
+ catch {
247
+ return null;
248
+ }
153
249
  }
package/dist/server.d.ts CHANGED
@@ -25,6 +25,13 @@ export declare function shellRedirect<TState = unknown>(url: string): ShellRespo
25
25
  export declare const shellSideEffect: {
26
26
  setLocalStorage: (key: string, value: string) => ShellSideEffect;
27
27
  setSessionStorage: (key: string, value: string) => ShellSideEffect;
28
+ /** Server-decided authenticated download. The shell fetches `url` with
29
+ * getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
30
+ * Content-Disposition + Content-Type, and saves via Adapter.saveFile.
31
+ * `filename` is a fallback used only when Content-Disposition is absent.
32
+ * The conditional spread keeps `filename` ABSENT (not undefined) on the
33
+ * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
34
+ download: (url: string, filename?: string) => ShellSideEffect;
28
35
  };
29
36
  /**
30
37
  * Web Fetch API–native request handler factory. Auto-detects content-type
package/dist/server.js CHANGED
@@ -50,6 +50,13 @@ export function shellRedirect(url) {
50
50
  export const shellSideEffect = {
51
51
  setLocalStorage: (key, value) => ({ type: "set-local-storage", key, value }),
52
52
  setSessionStorage: (key, value) => ({ type: "set-session-storage", key, value }),
53
+ /** Server-decided authenticated download. The shell fetches `url` with
54
+ * getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
55
+ * Content-Disposition + Content-Type, and saves via Adapter.saveFile.
56
+ * `filename` is a fallback used only when Content-Disposition is absent.
57
+ * The conditional spread keeps `filename` ABSENT (not undefined) on the
58
+ * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
59
+ download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
53
60
  };
54
61
  // ─── Action handler factory ──────────────────────────────────────────────────
55
62
  /**
package/dist/tui-cli.js CHANGED
@@ -1,18 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  // vms-tui — drive any ViewModel Shell backend from a terminal.
3
3
  //
4
- // vms-tui <endpoint-url>
5
- // e.g. vms-tui http://localhost:3000/api/tasks
4
+ // bunx vms-tui <endpoint-url>
5
+ // e.g. bunx vms-tui http://localhost:3000/api/tasks
6
6
  //
7
7
  // Convention: actions POST to `<endpoint>/action` (matches the demos:
8
- // GET /api/tasks + POST /api/tasks/action). A future flag can override it.
8
+ // GET /api/tasks + POST /api/tasks/action).
9
9
  //
10
- // Pure entrypoint never imported as a library (the importable surface is
11
- // TuiAdapter via the "./tui" export), so no import.meta.main guard is needed.
12
- import { ViewModelShell } from "./index.js";
10
+ // Runtime requirement (0.6.0+): Bun. The shebang above is Node so that an
11
+ // accidental `npx vms-tui …` prints a clear "needs Bun" message instead of
12
+ // crashing inside an FFI-laden OpenTUI import. Under `bunx`, `typeof Bun`
13
+ // is defined and the script proceeds normally.
13
14
  const USAGE = "Usage: vms-tui <endpoint-url>\n" +
14
- " e.g. vms-tui http://localhost:3000/api/tasks\n" +
15
- " Actions POST to <endpoint>/action.";
15
+ " e.g. bunx vms-tui http://localhost:3000/api/tasks\n" +
16
+ " Actions POST to <endpoint>/action.\n" +
17
+ " Requires Bun runtime: https://bun.sh/install";
18
+ // ── Bun runtime guard ─────────────────────────────────────────────────────
19
+ // vms-tui's TUI substrate (OpenTUI) uses Bun's FFI APIs and is not yet
20
+ // Node-compatible. Failing fast with a clear message here beats a confusing
21
+ // ESM resolution stack trace deeper in the import chain.
22
+ if (typeof Bun === "undefined") {
23
+ process.stderr.write("vms-tui requires the Bun runtime.\n" +
24
+ " Install: curl -fsSL https://bun.sh/install | bash\n" +
25
+ " Then run: bunx vms-tui <endpoint-url>\n" +
26
+ "(see https://bun.sh for other installers)\n");
27
+ process.exit(1);
28
+ }
16
29
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
30
  async function main() {
18
31
  const arg = process.argv[2];
@@ -22,7 +35,6 @@ async function main() {
22
35
  return;
23
36
  }
24
37
  try {
25
- // Validate; rejects garbage early instead of failing deep in fetch.
26
38
  void new URL(arg);
27
39
  }
28
40
  catch {
@@ -31,55 +43,44 @@ async function main() {
31
43
  return;
32
44
  }
33
45
  const endpoint = arg;
34
- // Guarded dynamic import: if the optional `ink`/`react` deps are absent,
35
- // fail loud with an install hint instead of an ESM resolution stack trace.
46
+ // Dynamic import under Bun, this resolves to the OpenTUI-backed adapter.
47
+ // Under Node we'd never get here (the Bun guard above exits first).
36
48
  let TuiAdapter;
37
- let openExternal;
38
- let classify;
39
49
  try {
40
- ({ TuiAdapter, openExternal, classify } = await import("./tui.js"));
50
+ ({ TuiAdapter } = await import("./tui.js"));
41
51
  }
42
52
  catch (err) {
43
- process.stderr.write("vms-tui requires its optional peer packages: 'ink', 'react', " +
44
- "'ink-text-input', 'ink-select-input'.\n" +
45
- "Install them: npm i ink react ink-text-input ink-select-input\n" +
53
+ process.stderr.write("vms-tui: failed to load the TUI adapter — make sure '@opentui/core' " +
54
+ "and '@opentui/react' are installed.\n" +
55
+ " bun install @opentui/core @opentui/react react\n" +
46
56
  `(load error: ${err.message})\n`);
47
57
  process.exitCode = 1;
48
58
  return;
49
59
  }
60
+ // Dynamically import ViewModelShell (the framework core; not Bun-specific)
61
+ // alongside, to keep the import topology clean.
62
+ const { ViewModelShell } = await import("./index.js");
50
63
  const adapter = new TuiAdapter();
51
64
  let loadFailed = false;
52
65
  let exiting = false;
53
- // Single, idempotent teardown owned by the CLI.
54
- //
55
- // Ink registers `signal-exit` and resolving its waitUntilExit() promise
56
- // (which adapter.dispose() → Ink unmount does) lets main()'s tail resume.
57
- // Without the `exiting` guard that tail would set process.exitCode = 0 and
58
- // race the real signal code away (observed: SIGINT exited 0, not 130).
59
- // So: set the code FIRST, restore the terminal, then exit — and let the
60
- // first caller win; any later caller (incl. the resumed await-tail) no-ops.
61
66
  const shutdown = (code) => {
62
67
  if (exiting)
63
68
  return;
64
69
  exiting = true;
65
70
  process.exitCode = code;
66
71
  try {
67
- adapter.dispose(); // Ink unmount → cursor/raw-mode restored
68
- }
69
- catch {
70
- /* best-effort, idempotent */
72
+ adapter.dispose();
71
73
  }
74
+ catch { /* idempotent */ }
72
75
  process.exit(code);
73
76
  };
74
77
  process.once("SIGINT", () => shutdown(130));
75
78
  process.once("SIGTERM", () => shutdown(143));
76
79
  process.once("uncaughtException", (err) => {
77
80
  try {
78
- adapter.dispose(); // restore BEFORE printing so the message is readable
79
- }
80
- catch {
81
- /* idempotent */
81
+ adapter.dispose();
82
82
  }
83
+ catch { /* idempotent */ }
83
84
  process.stderr.write(`vms-tui: ${err?.stack ?? String(err)}\n`);
84
85
  shutdown(1);
85
86
  });
@@ -87,74 +88,45 @@ async function main() {
87
88
  try {
88
89
  adapter.dispose();
89
90
  }
90
- catch {
91
- /* idempotent */
92
- }
91
+ catch { /* idempotent */ }
93
92
  process.stderr.write(`vms-tui: ${String(reason)}\n`);
94
93
  shutdown(1);
95
94
  });
96
- // Final safety net: any other exit path still restores the terminal.
97
95
  process.once("exit", () => {
98
96
  try {
99
97
  adapter.dispose();
100
98
  }
101
- catch {
102
- /* idempotent */
103
- }
99
+ catch { /* idempotent */ }
104
100
  });
105
- // Phase 2: the adapter now uses Ink input hooks Ink puts stdin in raw
106
- // mode, which clears ISIG, so a *keyboard* Ctrl-C is delivered as input
107
- // byte 0x03 and never raises SIGINT (the process.once("SIGINT") above only
108
- // fires for a *programmatic* kill -INT). Route that keyboard Ctrl-C, caught
109
- // in the adapter's input handler, into this same idempotent shutdown so the
110
- // terminal is always restored. SIGTERM and programmatic SIGINT are
111
- // unaffected by raw mode and keep working via the handlers above. (The
112
- // keep-alive below is now redundant on the TTY path — Ink's resumed raw
113
- // stdin holds the loop — but is harmless and kept as belt-and-suspenders.)
114
- adapter.setRequestExit((code) => shutdown(code));
115
- // Phase 4: server-initiated redirects via the existing core onRedirect seam
116
- // (no new wire). A ViewModelShell's endpoint is immutable and load() takes
117
- // no URL, so a same-origin redirect is followed by building a FRESH shell
118
- // that reuses the SAME single adapter (Ink rerenders in place — no remount,
119
- // teardown topology unchanged). connect()'s options carry this same
120
- // onRedirect, so redirects chain.
121
- let redirectFailed = false;
122
- let currentShell;
123
- // Non-interactive iff EITHER stream is not a TTY. Checking stdin too (not
124
- // just stdout) is load-bearing: with the tui.tsx isActive fix the App's
125
- // useInput is correctly inert on non-TTY stdin, so nothing holds the event
126
- // loop — if stdout were a TTY but stdin a pipe (`vms-tui url </dev/null`
127
- // from an interactive shell), a stdout-only guard would fall through to the
128
- // keep-alive + waitUntilExit() and HANG forever (no input can ever arrive).
129
- // One static frame + exit is the only correct behavior when stdin is dead.
101
+ // Non-interactive: render one frame, then exit. Either stream not being a
102
+ // TTY is the signal a stdout TTY with a piped stdin would hang forever
103
+ // (no input can ever arrive), so we check both.
130
104
  const nonInteractive = !process.stdout.isTTY || !process.stdin.isTTY;
105
+ // ── Redirect handling (B1: minimal) ────────────────────────────────────
106
+ // Same-origin: rebuild the shell against the new endpoint and reload, so
107
+ // the polling/state cycle continues seamlessly. Different-origin or
108
+ // invalid: delegate to adapter.navigate (which spawns xdg-open / open
109
+ // / start), or, on non-TTY, print a loud stderr line. B5 will re-add
110
+ // the in-app interstitial UX the Ink CLI had.
111
+ let currentShell;
131
112
  const handleRedirect = (url, fromEndpoint) => {
132
- const c = classify(url, fromEndpoint);
113
+ const c = classifyRedirect(url, fromEndpoint);
133
114
  if (c.kind === "same-origin") {
134
- currentShell.stopPolling(); // no timer armed today; defends a future one
115
+ currentShell.stopPolling();
135
116
  currentShell = connect(c.endpoint);
136
- void currentShell.load(); // failures flow through the shared onError
117
+ void currentShell.load();
137
118
  return;
138
119
  }
139
120
  if (nonInteractive) {
140
- // Non-interactive: never spawn a browser; loud stderr + nonzero exit via
141
- // the SINGLE shutdown funnel (redirectFailed is read at the exit site).
142
121
  process.stderr.write(`vms-tui: cannot follow redirect (${c.kind}): ${url}\n`);
143
- redirectFailed = true;
122
+ loadFailed = true;
144
123
  return;
145
124
  }
146
125
  if (c.kind === "different-origin") {
147
- const detail = `This app asked to open an external URL:\n\n ${c.url}`;
148
- const fb = () => adapter.showInterstitial(`${detail}\n\n(no browser could be launched — open it manually)`);
149
- if (openExternal(c.url, fb)) {
150
- adapter.showInterstitial(`${detail}\n\n(opening in your browser…)`);
151
- }
152
- else {
153
- fb();
154
- }
126
+ adapter.navigate(c.url);
155
127
  }
156
128
  else {
157
- adapter.showInterstitial(`This app returned an invalid redirect:\n\n ${JSON.stringify(url)}`);
129
+ process.stderr.write(`vms-tui: invalid redirect URL: ${JSON.stringify(url)}\n`);
158
130
  }
159
131
  };
160
132
  const connect = (ep) => new ViewModelShell({
@@ -163,7 +135,6 @@ async function main() {
163
135
  adapter,
164
136
  onError: (err) => {
165
137
  loadFailed = true;
166
- // A stderr line guarantees visibility even before the first render.
167
138
  process.stderr.write(`vms-tui: ${err.message}\n`);
168
139
  },
169
140
  onRedirect: (url) => handleRedirect(url, ep),
@@ -177,26 +148,42 @@ async function main() {
177
148
  process.stderr.write(`vms-tui: ${err.message}\n`);
178
149
  }
179
150
  if (nonInteractive) {
180
- // Non-interactive (piped / CI / dead stdin): nothing to interact with —
181
- // emit one static frame and exit instead of hanging forever. The delay
182
- // lets Ink flush its throttled render. (Redirects can't fire here: load()
183
- // ignores response.redirect and non-TTY performs no dispatch — so
184
- // redirectFailed stays false unless a future code path dispatches.)
185
- await delay(80);
186
- return shutdown(loadFailed || redirectFailed ? 1 : 0);
151
+ // Let OpenTUI's renderer flush one frame, then exit. 100ms is generous;
152
+ // OpenTUI's render pipeline is microtask-fast.
153
+ await delay(100);
154
+ return shutdown(loadFailed ? 1 : 0);
187
155
  }
188
- // Load/connection failure → nothing rendered; exit now rather than wait for
189
- // a Ctrl-C the user has no reason to send.
190
156
  if (loadFailed) {
191
157
  return shutdown(1);
192
158
  }
193
- // TTY + rendered: keep the frame up until the user quits (Ctrl-C). Ink's
194
- // resumed raw stdin holds the loop (Phase 2+), but the no-op timer is kept
195
- // as belt-and-suspenders; the SIGINT/SIGTERM handlers own exit + restore.
196
- // (waitUntilExit() wins the race if Ink unmounts for another reason.)
197
- const keepAlive = setInterval(() => { }, 1 << 30);
198
- await adapter.waitUntilExit();
199
- clearInterval(keepAlive);
200
- shutdown(0);
159
+ // TTY + rendered: keep the frame up until the user quits (Ctrl-C / SIGTERM).
160
+ // OpenTUI's renderer owns the keep-alive (its async event loop holds the
161
+ // process), so we just await a never-resolving promise — SIGINT/SIGTERM
162
+ // handlers funnel into shutdown().
163
+ await new Promise(() => { });
164
+ }
165
+ function classifyRedirect(target, fromEndpoint) {
166
+ let from;
167
+ try {
168
+ from = new URL(fromEndpoint);
169
+ }
170
+ catch {
171
+ return { kind: "invalid", reason: "current endpoint is not a valid URL" };
172
+ }
173
+ let to;
174
+ try {
175
+ to = new URL(target, from);
176
+ }
177
+ catch {
178
+ return { kind: "invalid", reason: "redirect target is not a valid URL" };
179
+ }
180
+ if (to.protocol !== "http:" && to.protocol !== "https:") {
181
+ return { kind: "invalid", reason: `unsupported protocol: ${to.protocol}` };
182
+ }
183
+ if (to.origin === from.origin) {
184
+ return { kind: "same-origin", endpoint: to.toString() };
185
+ }
186
+ return { kind: "different-origin", url: to.toString() };
201
187
  }
202
188
  void main();
189
+ export {};