wotann 0.5.91 → 0.5.93
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/dist/cli/npx-self-update-hint.d.ts +60 -0
- package/dist/cli/npx-self-update-hint.js +145 -0
- package/dist/index.js +18 -2
- package/dist/ui/mount-interactive-ink.d.ts +9 -0
- package/dist/ui/mount-interactive-ink.js +19 -2
- package/dist/ui/raw-mode-guard.d.ts +59 -2
- package/dist/ui/raw-mode-guard.js +112 -5
- package/install.sh +48 -3
- package/package.json +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npx-self-update-hint — one-line nudge at startup when a wotann
|
|
3
|
+
* binary is running from the `~/.npm/_npx/<hash>/` cache AND a newer
|
|
4
|
+
* version is published on the npm registry.
|
|
5
|
+
*
|
|
6
|
+
* Background: `npx wotann` caches packages by name-hash for ~7 days.
|
|
7
|
+
* When wotann ships a new version, users on the cached older copy
|
|
8
|
+
* silently miss the fix until either (a) the cache expires, (b) they
|
|
9
|
+
* `npx --yes -p wotann@latest wotann`, or (c) they install globally.
|
|
10
|
+
* This module surfaces the upgrade path BEFORE the TUI mounts so the
|
|
11
|
+
* stale-cache failure mode is self-explanatory.
|
|
12
|
+
*
|
|
13
|
+
* Design: pure functions where possible, dependency-injected network +
|
|
14
|
+
* stderr for tests, short timeout (~1.5 s) so a slow registry never
|
|
15
|
+
* blocks startup, swallow all errors (a hint is best-effort, never a
|
|
16
|
+
* gate). Skip in CI/non-interactive contexts.
|
|
17
|
+
*/
|
|
18
|
+
export interface NpxUpdateHintOptions {
|
|
19
|
+
readonly currentVersion: string;
|
|
20
|
+
/** `__dirname` of the wotann install — used to detect npx cache path. */
|
|
21
|
+
readonly currentDirname: string;
|
|
22
|
+
/** Override the registry fetch (tests). */
|
|
23
|
+
readonly fetchLatest?: (timeoutMs: number) => Promise<string | null>;
|
|
24
|
+
/** Override the output sink (tests). */
|
|
25
|
+
readonly stderr?: {
|
|
26
|
+
write(s: string): unknown;
|
|
27
|
+
};
|
|
28
|
+
/** Maximum time to wait for the registry call. */
|
|
29
|
+
readonly timeoutMs?: number;
|
|
30
|
+
/** Skip the hint entirely when CI=true (default: true). */
|
|
31
|
+
readonly skipInCI?: boolean;
|
|
32
|
+
/** Skip when env signals non-interactive (default: true). */
|
|
33
|
+
readonly skipNonInteractive?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* True iff the given directory path is inside an npm npx cache slot
|
|
37
|
+
* (`~/.npm/_npx/<hash>/` on POSIX, `npm-cache\_npx\<hash>` on Windows).
|
|
38
|
+
*/
|
|
39
|
+
export declare function isRunningFromNpxCache(dirname: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Returns `1` if `a > b`, `-1` if `a < b`, `0` if equal. Stable for
|
|
42
|
+
* unequal-length segments (treats missing trailers as 0).
|
|
43
|
+
*/
|
|
44
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
45
|
+
/**
|
|
46
|
+
* GET https://registry.npmjs.org/wotann/latest with a hard timeout.
|
|
47
|
+
* Returns the published version string, or `null` on any failure
|
|
48
|
+
* (network error, non-JSON response, timeout, missing version field).
|
|
49
|
+
* Never throws.
|
|
50
|
+
*/
|
|
51
|
+
export declare function fetchLatestWotannVersion(timeoutMs: number): Promise<string | null>;
|
|
52
|
+
/**
|
|
53
|
+
* Run the full hint sequence: detect cache path → fetch latest →
|
|
54
|
+
* compare → optionally print. Never blocks longer than `timeoutMs`,
|
|
55
|
+
* never throws, never gates the rest of the startup.
|
|
56
|
+
*
|
|
57
|
+
* Returns the printed hint string (for tests) or `null` if no hint
|
|
58
|
+
* was printed.
|
|
59
|
+
*/
|
|
60
|
+
export declare function printNpxUpgradeHint(opts: NpxUpdateHintOptions): Promise<string | null>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npx-self-update-hint — one-line nudge at startup when a wotann
|
|
3
|
+
* binary is running from the `~/.npm/_npx/<hash>/` cache AND a newer
|
|
4
|
+
* version is published on the npm registry.
|
|
5
|
+
*
|
|
6
|
+
* Background: `npx wotann` caches packages by name-hash for ~7 days.
|
|
7
|
+
* When wotann ships a new version, users on the cached older copy
|
|
8
|
+
* silently miss the fix until either (a) the cache expires, (b) they
|
|
9
|
+
* `npx --yes -p wotann@latest wotann`, or (c) they install globally.
|
|
10
|
+
* This module surfaces the upgrade path BEFORE the TUI mounts so the
|
|
11
|
+
* stale-cache failure mode is self-explanatory.
|
|
12
|
+
*
|
|
13
|
+
* Design: pure functions where possible, dependency-injected network +
|
|
14
|
+
* stderr for tests, short timeout (~1.5 s) so a slow registry never
|
|
15
|
+
* blocks startup, swallow all errors (a hint is best-effort, never a
|
|
16
|
+
* gate). Skip in CI/non-interactive contexts.
|
|
17
|
+
*/
|
|
18
|
+
import { request as httpsRequest } from "node:https";
|
|
19
|
+
/**
|
|
20
|
+
* True iff the given directory path is inside an npm npx cache slot
|
|
21
|
+
* (`~/.npm/_npx/<hash>/` on POSIX, `npm-cache\_npx\<hash>` on Windows).
|
|
22
|
+
*/
|
|
23
|
+
export function isRunningFromNpxCache(dirname) {
|
|
24
|
+
const normalized = dirname.replace(/\\/g, "/");
|
|
25
|
+
return normalized.includes("/.npm/_npx/") || normalized.includes("/npm-cache/_npx/");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse a dot-separated semver-ish version into numeric segments.
|
|
29
|
+
* Non-numeric trailers (e.g. "-rc.1") are dropped — npm registry
|
|
30
|
+
* "latest" tag points at stable releases so leading numeric segments
|
|
31
|
+
* are enough for the "is current older than latest?" decision.
|
|
32
|
+
*/
|
|
33
|
+
function parseNumericVersionPrefix(v) {
|
|
34
|
+
const trimmed = v.trim();
|
|
35
|
+
const head = trimmed.split(/[-+]/)[0] ?? trimmed;
|
|
36
|
+
return head.split(".").map((s) => {
|
|
37
|
+
const n = Number.parseInt(s, 10);
|
|
38
|
+
return Number.isFinite(n) ? n : 0;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns `1` if `a > b`, `-1` if `a < b`, `0` if equal. Stable for
|
|
43
|
+
* unequal-length segments (treats missing trailers as 0).
|
|
44
|
+
*/
|
|
45
|
+
export function compareVersions(a, b) {
|
|
46
|
+
const A = parseNumericVersionPrefix(a);
|
|
47
|
+
const B = parseNumericVersionPrefix(b);
|
|
48
|
+
const len = Math.max(A.length, B.length);
|
|
49
|
+
for (let i = 0; i < len; i++) {
|
|
50
|
+
const x = A[i] ?? 0;
|
|
51
|
+
const y = B[i] ?? 0;
|
|
52
|
+
if (x > y)
|
|
53
|
+
return 1;
|
|
54
|
+
if (x < y)
|
|
55
|
+
return -1;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* GET https://registry.npmjs.org/wotann/latest with a hard timeout.
|
|
61
|
+
* Returns the published version string, or `null` on any failure
|
|
62
|
+
* (network error, non-JSON response, timeout, missing version field).
|
|
63
|
+
* Never throws.
|
|
64
|
+
*/
|
|
65
|
+
export function fetchLatestWotannVersion(timeoutMs) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
let settled = false;
|
|
68
|
+
const finish = (v) => {
|
|
69
|
+
if (settled)
|
|
70
|
+
return;
|
|
71
|
+
settled = true;
|
|
72
|
+
resolve(v);
|
|
73
|
+
};
|
|
74
|
+
const req = httpsRequest({
|
|
75
|
+
host: "registry.npmjs.org",
|
|
76
|
+
path: "/wotann/latest",
|
|
77
|
+
method: "GET",
|
|
78
|
+
headers: { "user-agent": "wotann-startup-check", accept: "application/json" },
|
|
79
|
+
}, (res) => {
|
|
80
|
+
if (res.statusCode !== 200) {
|
|
81
|
+
res.resume();
|
|
82
|
+
finish(null);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const chunks = [];
|
|
86
|
+
res.on("data", (c) => chunks.push(c));
|
|
87
|
+
res.on("end", () => {
|
|
88
|
+
try {
|
|
89
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
90
|
+
const parsed = JSON.parse(body);
|
|
91
|
+
finish(typeof parsed.version === "string" ? parsed.version : null);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
finish(null);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
res.on("error", () => finish(null));
|
|
98
|
+
});
|
|
99
|
+
req.on("error", () => finish(null));
|
|
100
|
+
req.setTimeout(timeoutMs, () => {
|
|
101
|
+
req.destroy();
|
|
102
|
+
finish(null);
|
|
103
|
+
});
|
|
104
|
+
req.end();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const HINT_LINE = (current, latest) => `[wotann] You're running v${current} from the npx cache; v${latest} is available. ` +
|
|
108
|
+
`Refresh with: npx --yes -p wotann@latest wotann\n`;
|
|
109
|
+
/**
|
|
110
|
+
* Run the full hint sequence: detect cache path → fetch latest →
|
|
111
|
+
* compare → optionally print. Never blocks longer than `timeoutMs`,
|
|
112
|
+
* never throws, never gates the rest of the startup.
|
|
113
|
+
*
|
|
114
|
+
* Returns the printed hint string (for tests) or `null` if no hint
|
|
115
|
+
* was printed.
|
|
116
|
+
*/
|
|
117
|
+
export async function printNpxUpgradeHint(opts) {
|
|
118
|
+
if ((opts.skipInCI ?? true) && process.env["CI"] === "true")
|
|
119
|
+
return null;
|
|
120
|
+
if ((opts.skipNonInteractive ?? true) && !process.stdout.isTTY)
|
|
121
|
+
return null;
|
|
122
|
+
if (!isRunningFromNpxCache(opts.currentDirname))
|
|
123
|
+
return null;
|
|
124
|
+
const fetcher = opts.fetchLatest ?? fetchLatestWotannVersion;
|
|
125
|
+
let latest;
|
|
126
|
+
try {
|
|
127
|
+
latest = await fetcher(opts.timeoutMs ?? 1500);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (latest === null)
|
|
133
|
+
return null;
|
|
134
|
+
if (compareVersions(opts.currentVersion, latest) >= 0)
|
|
135
|
+
return null;
|
|
136
|
+
const sink = opts.stderr ?? process.stderr;
|
|
137
|
+
const line = HINT_LINE(opts.currentVersion, latest);
|
|
138
|
+
try {
|
|
139
|
+
sink.write(line);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return line;
|
|
145
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -349,10 +349,26 @@ program
|
|
|
349
349
|
return; // thin TUI rendered and exited
|
|
350
350
|
// else fall through to full runtime
|
|
351
351
|
}
|
|
352
|
-
|
|
353
|
-
|
|
352
|
+
// Parallel-load the 3 heavy boot modules + the lightweight npx
|
|
353
|
+
// self-update-hint module, then fire the hint check in parallel
|
|
354
|
+
// with the runtime bootstrap. Net cost: zero added latency in the
|
|
355
|
+
// common case (the registry call completes during bootstrap);
|
|
356
|
+
// worst case: a sub-second wait if the network is slow.
|
|
357
|
+
const [ReactModule, { bootstrapInteractiveSession }, { printNpxUpgradeHint }] = await Promise.all([
|
|
358
|
+
import("react"),
|
|
359
|
+
import("./ui/bootstrap.js"),
|
|
360
|
+
import("./cli/npx-self-update-hint.js"),
|
|
361
|
+
]);
|
|
354
362
|
const React = ReactModule.default;
|
|
363
|
+
const npxHintPromise = printNpxUpgradeHint({
|
|
364
|
+
currentVersion: VERSION,
|
|
365
|
+
currentDirname: dirname(fileURLToPath(import.meta.url)),
|
|
366
|
+
}).catch(() => null);
|
|
355
367
|
let interactive = await bootstrapInteractiveSession(process.cwd(), options);
|
|
368
|
+
// Drain the hint promise so the line lands BEFORE the TUI takes
|
|
369
|
+
// the screen (or before we exit on refusal). It's a best-effort
|
|
370
|
+
// no-op when not applicable.
|
|
371
|
+
await npxHintPromise;
|
|
356
372
|
if (options.workspace !== undefined) {
|
|
357
373
|
const { writeWorkspaceResumeRecord } = await import("./cli/workspace-resume.js");
|
|
358
374
|
writeWorkspaceResumeRecord({
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { ReactElement } from "react";
|
|
23
23
|
import type { Instance } from "ink";
|
|
24
|
+
import { type StdinDiagnosis } from "./raw-mode-guard.js";
|
|
24
25
|
/** Actionable guidance when there is no raw-mode-capable terminal. */
|
|
25
26
|
export declare const DEFAULT_REFUSAL_MESSAGE: string;
|
|
26
27
|
type InkRender = (el: ReactElement, opts?: {
|
|
@@ -53,6 +54,14 @@ export interface MountInteractiveOptions {
|
|
|
53
54
|
* the stderr diagnostic stays visible in the main buffer.
|
|
54
55
|
*/
|
|
55
56
|
readonly onResolved?: () => void;
|
|
57
|
+
/**
|
|
58
|
+
* Override the stdin diagnostic walker (tests). Default: the real
|
|
59
|
+
* `diagnoseStdin` from raw-mode-guard. The diagnosis is appended to
|
|
60
|
+
* the DEFAULT refusal message — when a `refusalMessage` is provided
|
|
61
|
+
* explicitly, the diagnosis is NOT appended (preserve strict-equality
|
|
62
|
+
* test contracts and let callers control the full output).
|
|
63
|
+
*/
|
|
64
|
+
readonly diagnoseStdinFn?: () => StdinDiagnosis;
|
|
56
65
|
}
|
|
57
66
|
export interface MountInteractiveResult {
|
|
58
67
|
/**
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* bug class cannot silently regress when a new mount is added.
|
|
21
21
|
*/
|
|
22
22
|
import { ensureRenderableViewport } from "./viewport-guard.js";
|
|
23
|
-
import { resolveInteractiveStdin } from "./raw-mode-guard.js";
|
|
23
|
+
import { resolveInteractiveStdin, diagnoseStdin, formatStdinDiagnosis, } from "./raw-mode-guard.js";
|
|
24
24
|
/** Actionable guidance when there is no raw-mode-capable terminal. */
|
|
25
25
|
export const DEFAULT_REFUSAL_MESSAGE = "[wotann] No raw-mode-capable terminal available for the interactive TUI.\n" +
|
|
26
26
|
" stdin is piped or the launcher degraded the TTY (some `npx`\n" +
|
|
@@ -51,7 +51,24 @@ export async function mountInteractiveInk(element, opts = {}) {
|
|
|
51
51
|
const inputStdin = resolve();
|
|
52
52
|
if (inputStdin === null) {
|
|
53
53
|
const sink = opts.stderr ?? process.stderr;
|
|
54
|
-
|
|
54
|
+
if (opts.refusalMessage !== undefined) {
|
|
55
|
+
// Caller provided a full custom message — write it verbatim, no
|
|
56
|
+
// diagnosis appended (preserves strict-equality test contracts).
|
|
57
|
+
sink.write(opts.refusalMessage);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
sink.write(DEFAULT_REFUSAL_MESSAGE);
|
|
61
|
+
// Append a per-step diagnosis so the user sees WHY the guard
|
|
62
|
+
// refused (which step failed and what it observed). Future bug
|
|
63
|
+
// reports include the evidence automatically.
|
|
64
|
+
try {
|
|
65
|
+
const diagnose = opts.diagnoseStdinFn ?? diagnoseStdin;
|
|
66
|
+
sink.write(formatStdinDiagnosis(diagnose()));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Diagnostic must never block the refusal — swallow.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
55
72
|
return { instance: null, refused: true };
|
|
56
73
|
}
|
|
57
74
|
const inkRender = opts.inkRender ?? (await import("ink")).render;
|
|
@@ -46,10 +46,67 @@ export interface ResolveStdinOptions {
|
|
|
46
46
|
}
|
|
47
47
|
/**
|
|
48
48
|
* Resolve an input stream Ink can drive `useInput()` with:
|
|
49
|
-
* 1. `
|
|
50
|
-
* 2. else
|
|
49
|
+
* 1. controlling terminal (`/dev/tty`) if raw-capable — preferred.
|
|
50
|
+
* 2. else `stdin` if it is raw-capable — fallback for CI / Windows /
|
|
51
|
+
* detached processes that have no controlling terminal.
|
|
51
52
|
* 3. else `null` — caller MUST NOT mount the interactive Ink app
|
|
52
53
|
* (it would hang); print guidance + exit instead.
|
|
54
|
+
*
|
|
55
|
+
* Why /dev/tty FIRST (changed 2026-05-28 from stdin-first):
|
|
56
|
+
* Under `npx wotann`, `npm exec`, `sudo`, and other launchers, the
|
|
57
|
+
* subprocess's `process.stdin` can pass the static shape check
|
|
58
|
+
* (`isTTY === true`, `setRawMode` is a function) yet behave oddly
|
|
59
|
+
* when Ink actually drives it — the probe's no-op `setRawMode(false)`
|
|
60
|
+
* succeeds while Ink's `setRawMode(true)` then fails, the stream is in
|
|
61
|
+
* flowing mode because some boot import attached a `data` listener,
|
|
62
|
+
* the launcher transformed the descriptor, etc. The controlling
|
|
63
|
+
* terminal is the canonical "user's keyboard" source and is
|
|
64
|
+
* unaffected by any of that — the same pattern `less`, `vim`, `sudo`,
|
|
65
|
+
* and `git rebase -i` use for stdin-needs-tty scenarios. The fallback
|
|
66
|
+
* to `stdin` covers the no-`/dev/tty` cases (CI containers, Windows
|
|
67
|
+
* without a console, detached processes).
|
|
68
|
+
*
|
|
53
69
|
* Never throws.
|
|
54
70
|
*/
|
|
55
71
|
export declare function resolveInteractiveStdin(stdin?: unknown, opts?: ResolveStdinOptions): NodeJS.ReadStream | null;
|
|
72
|
+
/**
|
|
73
|
+
* Structured per-step diagnosis of why the resolver did or did not return
|
|
74
|
+
* a raw-capable stream. The shape mirrors the steps of
|
|
75
|
+
* `resolveInteractiveStdin` so a "guard refused under a raw-capable
|
|
76
|
+
* terminal" report carries enough evidence to identify which step failed
|
|
77
|
+
* without shipping a custom diagnostic build.
|
|
78
|
+
*/
|
|
79
|
+
export interface StdinDiagnosis {
|
|
80
|
+
/** Whatever `stdin.isTTY` returned (true/false/undefined/etc.). */
|
|
81
|
+
readonly stdinIsTTY: unknown;
|
|
82
|
+
/** Whether `stdin.setRawMode` is a function (not whether it works). */
|
|
83
|
+
readonly stdinHasSetRawMode: boolean;
|
|
84
|
+
/** "ok" if the probe call succeeded, "missing" if no setRawMode, else the error message. */
|
|
85
|
+
readonly stdinProbeResult: string;
|
|
86
|
+
/** "ok", "not-attempted", "returned null", or the open error message. */
|
|
87
|
+
readonly ttyOpenResult: string;
|
|
88
|
+
/** Whatever the opened /dev/tty stream's `isTTY` returned, when applicable. */
|
|
89
|
+
readonly ttyIsTTY?: unknown;
|
|
90
|
+
/** Whether the opened /dev/tty stream has a `setRawMode` function. */
|
|
91
|
+
readonly ttyHasSetRawMode?: boolean;
|
|
92
|
+
/** "ok", "missing", or the error message — only set when /dev/tty open succeeded. */
|
|
93
|
+
readonly ttyProbeResult?: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Records the full state of BOTH potential input sources — /dev/tty
|
|
97
|
+
* (the preferred source after the 2026-05-28 reorder) and `stdin`
|
|
98
|
+
* (the fallback). Always walks both regardless of whether the
|
|
99
|
+
* resolver would have short-circuited, because the diagnostic is most
|
|
100
|
+
* useful when something looks wrong with one source: the user / next
|
|
101
|
+
* agent gets the full picture, not "stdin worked, didn't look at tty".
|
|
102
|
+
*
|
|
103
|
+
* Never throws.
|
|
104
|
+
*/
|
|
105
|
+
export declare function diagnoseStdin(stdin?: unknown, opts?: ResolveStdinOptions): StdinDiagnosis;
|
|
106
|
+
/**
|
|
107
|
+
* Format a `StdinDiagnosis` as a short multi-line human-readable block
|
|
108
|
+
* suitable for stderr. Compact (~4 lines) so it doesn't dominate the
|
|
109
|
+
* refusal output but carries every piece of evidence needed to
|
|
110
|
+
* identify which step failed.
|
|
111
|
+
*/
|
|
112
|
+
export declare function formatStdinDiagnosis(d: StdinDiagnosis): string;
|
|
@@ -72,22 +72,129 @@ function defaultOpenControllingTty() {
|
|
|
72
72
|
}
|
|
73
73
|
/**
|
|
74
74
|
* Resolve an input stream Ink can drive `useInput()` with:
|
|
75
|
-
* 1. `
|
|
76
|
-
* 2. else
|
|
75
|
+
* 1. controlling terminal (`/dev/tty`) if raw-capable — preferred.
|
|
76
|
+
* 2. else `stdin` if it is raw-capable — fallback for CI / Windows /
|
|
77
|
+
* detached processes that have no controlling terminal.
|
|
77
78
|
* 3. else `null` — caller MUST NOT mount the interactive Ink app
|
|
78
79
|
* (it would hang); print guidance + exit instead.
|
|
80
|
+
*
|
|
81
|
+
* Why /dev/tty FIRST (changed 2026-05-28 from stdin-first):
|
|
82
|
+
* Under `npx wotann`, `npm exec`, `sudo`, and other launchers, the
|
|
83
|
+
* subprocess's `process.stdin` can pass the static shape check
|
|
84
|
+
* (`isTTY === true`, `setRawMode` is a function) yet behave oddly
|
|
85
|
+
* when Ink actually drives it — the probe's no-op `setRawMode(false)`
|
|
86
|
+
* succeeds while Ink's `setRawMode(true)` then fails, the stream is in
|
|
87
|
+
* flowing mode because some boot import attached a `data` listener,
|
|
88
|
+
* the launcher transformed the descriptor, etc. The controlling
|
|
89
|
+
* terminal is the canonical "user's keyboard" source and is
|
|
90
|
+
* unaffected by any of that — the same pattern `less`, `vim`, `sudo`,
|
|
91
|
+
* and `git rebase -i` use for stdin-needs-tty scenarios. The fallback
|
|
92
|
+
* to `stdin` covers the no-`/dev/tty` cases (CI containers, Windows
|
|
93
|
+
* without a console, detached processes).
|
|
94
|
+
*
|
|
79
95
|
* Never throws.
|
|
80
96
|
*/
|
|
81
97
|
export function resolveInteractiveStdin(stdin = process.stdin, opts = {}) {
|
|
98
|
+
const open = opts.openControllingTty ?? defaultOpenControllingTty;
|
|
99
|
+
let tty = null;
|
|
100
|
+
try {
|
|
101
|
+
tty = open();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
tty = null;
|
|
105
|
+
}
|
|
106
|
+
if (tty !== null && isRawModeCapable(tty))
|
|
107
|
+
return tty;
|
|
82
108
|
if (isRawModeCapable(stdin))
|
|
83
109
|
return stdin;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Same observation pattern as `isRawModeCapable` but captures the
|
|
114
|
+
* probe outcome instead of collapsing to true/false. Pure, never throws.
|
|
115
|
+
*/
|
|
116
|
+
function probeStdinDetail(stream) {
|
|
117
|
+
if (stream === null || typeof stream !== "object") {
|
|
118
|
+
return { isTTY: stream, hasSetRawMode: false, probeResult: "missing" };
|
|
119
|
+
}
|
|
120
|
+
const s = stream;
|
|
121
|
+
const hasSetRawMode = typeof s.setRawMode === "function";
|
|
122
|
+
if (s.isTTY !== true || !hasSetRawMode) {
|
|
123
|
+
return {
|
|
124
|
+
isTTY: s.isTTY,
|
|
125
|
+
hasSetRawMode,
|
|
126
|
+
probeResult: hasSetRawMode ? "skipped (isTTY!=true)" : "missing",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const setRawMode = s.setRawMode;
|
|
131
|
+
const currentIsRaw = s.isRaw === true;
|
|
132
|
+
setRawMode(currentIsRaw);
|
|
133
|
+
return { isTTY: true, hasSetRawMode: true, probeResult: "ok" };
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
return {
|
|
137
|
+
isTTY: true,
|
|
138
|
+
hasSetRawMode: true,
|
|
139
|
+
probeResult: e instanceof Error ? e.message : String(e),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Records the full state of BOTH potential input sources — /dev/tty
|
|
145
|
+
* (the preferred source after the 2026-05-28 reorder) and `stdin`
|
|
146
|
+
* (the fallback). Always walks both regardless of whether the
|
|
147
|
+
* resolver would have short-circuited, because the diagnostic is most
|
|
148
|
+
* useful when something looks wrong with one source: the user / next
|
|
149
|
+
* agent gets the full picture, not "stdin worked, didn't look at tty".
|
|
150
|
+
*
|
|
151
|
+
* Never throws.
|
|
152
|
+
*/
|
|
153
|
+
export function diagnoseStdin(stdin = process.stdin, opts = {}) {
|
|
84
154
|
const open = opts.openControllingTty ?? defaultOpenControllingTty;
|
|
85
155
|
let tty = null;
|
|
156
|
+
let ttyOpenResult;
|
|
86
157
|
try {
|
|
87
158
|
tty = open();
|
|
159
|
+
ttyOpenResult = tty === null ? "returned null" : "ok";
|
|
88
160
|
}
|
|
89
|
-
catch {
|
|
90
|
-
|
|
161
|
+
catch (e) {
|
|
162
|
+
ttyOpenResult = e instanceof Error ? e.message : String(e);
|
|
163
|
+
}
|
|
164
|
+
const stdinProbe = probeStdinDetail(stdin);
|
|
165
|
+
if (tty === null) {
|
|
166
|
+
return {
|
|
167
|
+
stdinIsTTY: stdinProbe.isTTY,
|
|
168
|
+
stdinHasSetRawMode: stdinProbe.hasSetRawMode,
|
|
169
|
+
stdinProbeResult: stdinProbe.probeResult,
|
|
170
|
+
ttyOpenResult,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const ttyProbe = probeStdinDetail(tty);
|
|
174
|
+
return {
|
|
175
|
+
stdinIsTTY: stdinProbe.isTTY,
|
|
176
|
+
stdinHasSetRawMode: stdinProbe.hasSetRawMode,
|
|
177
|
+
stdinProbeResult: stdinProbe.probeResult,
|
|
178
|
+
ttyOpenResult,
|
|
179
|
+
ttyIsTTY: ttyProbe.isTTY,
|
|
180
|
+
ttyHasSetRawMode: ttyProbe.hasSetRawMode,
|
|
181
|
+
ttyProbeResult: ttyProbe.probeResult,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Format a `StdinDiagnosis` as a short multi-line human-readable block
|
|
186
|
+
* suitable for stderr. Compact (~4 lines) so it doesn't dominate the
|
|
187
|
+
* refusal output but carries every piece of evidence needed to
|
|
188
|
+
* identify which step failed.
|
|
189
|
+
*/
|
|
190
|
+
export function formatStdinDiagnosis(d) {
|
|
191
|
+
const lines = [
|
|
192
|
+
" Diagnostic:",
|
|
193
|
+
` stdin: isTTY=${String(d.stdinIsTTY)} setRawMode=${d.stdinHasSetRawMode ? "yes" : "no"} probe=${d.stdinProbeResult}`,
|
|
194
|
+
` /dev/tty: open=${d.ttyOpenResult}`,
|
|
195
|
+
];
|
|
196
|
+
if (d.ttyProbeResult !== undefined) {
|
|
197
|
+
lines.push(` isTTY=${String(d.ttyIsTTY)} setRawMode=${d.ttyHasSetRawMode ? "yes" : "no"} probe=${d.ttyProbeResult}`);
|
|
91
198
|
}
|
|
92
|
-
return
|
|
199
|
+
return lines.join("\n") + "\n";
|
|
93
200
|
}
|
package/install.sh
CHANGED
|
@@ -96,10 +96,42 @@ if ! command -v npm >/dev/null 2>&1; then
|
|
|
96
96
|
exit 1
|
|
97
97
|
fi
|
|
98
98
|
|
|
99
|
+
# ---------------------------------------------------------------- Disk-space pre-flight
|
|
100
|
+
# Partial installs are the #1 silent-failure class on disk-pressured
|
|
101
|
+
# systems: `npm install -g` exits 0, but dist/index.js gets truncated
|
|
102
|
+
# during the atomic rename, leaving a wotann binary on PATH that runs
|
|
103
|
+
# `node empty.js` → exits 0 with zero output. Refuse install when free
|
|
104
|
+
# space is below 5GB so the user notices BEFORE the install pretends
|
|
105
|
+
# to succeed. Override with WOTANN_FORCE_INSTALL=1 if you know better.
|
|
106
|
+
AVAIL_KB="$(df -k "$HOME" 2>/dev/null | awk 'NR==2 {print $4}')"
|
|
107
|
+
if [ -n "${AVAIL_KB:-}" ] && [ "$AVAIL_KB" -lt 5242880 ]; then
|
|
108
|
+
AVAIL_GB="$(( AVAIL_KB / 1048576 ))"
|
|
109
|
+
echo -e "${YELLOW}WARN: Only ${AVAIL_GB}GB free on \$HOME — risky for npm install -g.${NC}"
|
|
110
|
+
echo -e "${DIM} Below 5GB, partial installs can leave a broken binary on PATH"
|
|
111
|
+
echo -e "${DIM} (causes 'wotann does nothing'). Free some space or override:${NC}"
|
|
112
|
+
echo -e "${DIM} WOTANN_FORCE_INSTALL=1 ./install.sh${NC}"
|
|
113
|
+
if [ "${WOTANN_FORCE_INSTALL:-0}" != "1" ]; then
|
|
114
|
+
exit 1
|
|
115
|
+
fi
|
|
116
|
+
fi
|
|
117
|
+
|
|
99
118
|
# ---------------------------------------------------------------- Idempotency
|
|
119
|
+
# Broken-install detection: if wotann is on PATH but `--version` doesn't
|
|
120
|
+
# print a `\d+\.\d+\.\d+` within 5 seconds, the previous install is
|
|
121
|
+
# half-applied. Uninstall before reinstalling so we don't keep the
|
|
122
|
+
# broken bin on PATH and shadow the fresh `npm install -g` we're about
|
|
123
|
+
# to run. This is the failure mode that masked 3 prior "fixes" — see
|
|
124
|
+
# case_npm_install_global_postinstall.md.
|
|
100
125
|
if command -v wotann >/dev/null 2>&1; then
|
|
101
|
-
CURRENT="$(wotann --version 2>/dev/null ||
|
|
102
|
-
|
|
126
|
+
CURRENT="$(perl -e 'alarm 5; exec @ARGV' wotann --version 2>/dev/null || true)"
|
|
127
|
+
if [ -z "$CURRENT" ] || ! printf "%s" "$CURRENT" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+"; then
|
|
128
|
+
echo -e "${YELLOW}Existing wotann install is broken (no version output).${NC}"
|
|
129
|
+
echo -e "${DIM}Uninstalling before fresh install...${NC}"
|
|
130
|
+
npm uninstall -g wotann >/dev/null 2>&1 || true
|
|
131
|
+
hash -r 2>/dev/null || true
|
|
132
|
+
else
|
|
133
|
+
echo -e "${DIM}Existing install detected: ${CURRENT}. Reinstalling...${NC}"
|
|
134
|
+
fi
|
|
103
135
|
fi
|
|
104
136
|
|
|
105
137
|
# ---------------------------------------------------------------- Install
|
|
@@ -161,7 +193,20 @@ if ! command -v wotann >/dev/null 2>&1; then
|
|
|
161
193
|
case ":$PATH:" in *":$NPM_BIN:"*) ;; *) PATH="$NPM_BIN:$PATH" ;; esac
|
|
162
194
|
fi
|
|
163
195
|
if command -v wotann >/dev/null 2>&1; then
|
|
164
|
-
|
|
196
|
+
# Post-install verification: a successful `npm install -g` doesn't
|
|
197
|
+
# imply a working binary. Time-limit the version probe so a hang
|
|
198
|
+
# from a partially-installed dist/index.js doesn't lock the script.
|
|
199
|
+
VERIFY_OUT="$(perl -e 'alarm 10; exec @ARGV' wotann --version 2>&1 || true)"
|
|
200
|
+
if [ -z "$VERIFY_OUT" ] || ! printf "%s" "$VERIFY_OUT" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+"; then
|
|
201
|
+
echo -e "${RED}ERROR: wotann installed but \`--version\` returned no version string.${NC}"
|
|
202
|
+
echo -e "${DIM} Captured output: ${VERIFY_OUT:-<empty>}${NC}"
|
|
203
|
+
echo -e "${DIM} This is the partial-install class — likely disk pressure or"
|
|
204
|
+
echo -e "${DIM} a postinstall script that didn't complete. Recovery:${NC}"
|
|
205
|
+
echo -e "${DIM} npm uninstall -g wotann${NC}"
|
|
206
|
+
echo -e "${DIM} # free disk space (need ≥5GB), then re-run install.sh${NC}"
|
|
207
|
+
exit 1
|
|
208
|
+
fi
|
|
209
|
+
echo -e "${GREEN}OK: WOTANN ${VERIFY_OUT} installed and verified${NC}"
|
|
165
210
|
else
|
|
166
211
|
echo -e "${RED}wotann command still not on PATH. Add npm global bin to PATH:${NC}"
|
|
167
212
|
echo -e "${DIM} export PATH=\"\$(npm bin -g):\$PATH\"${NC}"
|