@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 +26 -6
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +20 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.js +99 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +7 -0
- package/dist/tui-cli.js +85 -98
- package/dist/tui.d.ts +65 -141
- package/dist/tui.js +1064 -1535
- package/package.json +9 -11
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
|
-
|
|
57
|
+
bunx vms-tui https://your-app.example/api/tasks
|
|
42
58
|
```
|
|
43
59
|
|
|
44
|
-
The action endpoint is derived by convention (`<endpoint>/action`).
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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,
|
|
105
|
-
`redirect that never happens
|
|
106
|
-
|
|
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).
|
|
8
|
+
// GET /api/tasks + POST /api/tasks/action).
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
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
|
-
//
|
|
35
|
-
//
|
|
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
|
|
50
|
+
({ TuiAdapter } = await import("./tui.js"));
|
|
41
51
|
}
|
|
42
52
|
catch (err) {
|
|
43
|
-
process.stderr.write("vms-tui
|
|
44
|
-
"'
|
|
45
|
-
"
|
|
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();
|
|
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();
|
|
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
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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 =
|
|
113
|
+
const c = classifyRedirect(url, fromEndpoint);
|
|
133
114
|
if (c.kind === "same-origin") {
|
|
134
|
-
currentShell.stopPolling();
|
|
115
|
+
currentShell.stopPolling();
|
|
135
116
|
currentShell = connect(c.endpoint);
|
|
136
|
-
void currentShell.load();
|
|
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
|
-
|
|
122
|
+
loadFailed = true;
|
|
144
123
|
return;
|
|
145
124
|
}
|
|
146
125
|
if (c.kind === "different-origin") {
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
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).
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 {};
|