@gaia-ai/ui 0.8.0 → 0.9.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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * GAIA-326 — `@opentui/core` reaches its Zig core through FFI, which Node does
3
+ * not expose before 26.1 (and then only behind `--experimental-ffi`). So the
4
+ * cockpit runs under the `bun` that `@gaia-ai/addon-gaia-ui` installs.
5
+ *
6
+ * `process.versions.bun` is the loop guard: it is defined under Bun and nowhere
7
+ * else, so a re-exec can never recurse and no sentinel variable is introduced.
8
+ * `--print-config` deliberately stays on Node — it is what a human runs when the
9
+ * cockpit will not start, and a diagnostic that needs the thing it diagnoses is
10
+ * worth nothing.
11
+ *
12
+ * `injectedRenderer` is the same argument one step further: the runtime is
13
+ * needed by the DYNAMICALLY-resolved `@gaia-ai/addon-gaia-ui`, so a caller that
14
+ * supplies its own `runUi` never reaches OpenTUI and must not be re-execed.
15
+ */
16
+ export declare function shouldReExecUnderBun(input: {
17
+ bunVersion?: string | undefined;
18
+ noBun?: string | undefined;
19
+ printConfig: boolean;
20
+ injectedRenderer?: boolean | undefined;
21
+ }): boolean;
22
+ /**
23
+ * Resolve the `bun` executable from the renderer addon's own base — the same
24
+ * dynamic seam `loadGaiaUiModule` resolves the renderer through, so the one
25
+ * dynamic edge in this package stays one edge.
26
+ *
27
+ * The manifest is read with a plain `readFileSync` rather than through the
28
+ * resolver: `createRequire(...).resolve()` gives an absolute path, and reading a
29
+ * known path needs no module system. `bun`'s manifest is resolved by name
30
+ * (`bun/package.json`) because the launcher's location is the thing we are
31
+ * looking up; its `bin` entry is a relative path from the manifest's directory.
32
+ */
33
+ export declare function resolveBunBinary(bases: string[]): string | undefined;
34
+ export type LibcFamily = 'glibc' | 'musl' | 'unknown';
35
+ /**
36
+ * GAIA-326 (A1) — which C library is this host running?
37
+ *
38
+ * Two evidence sources, cheapest first, both injected so the answer never
39
+ * depends on the machine the test runs on:
40
+ *
41
+ * - `/proc/self/maps` (~0.1 ms) names the interpreter the kernel ACTUALLY
42
+ * mapped into this process. That is the strongest signal available: a glibc
43
+ * box with a musl-compat package installed has `/lib/ld-musl-x86_64.so.1` on
44
+ * disk but never maps it, so the `existsSync` shortcut would misread it.
45
+ * - the node report's `header.glibcVersionRuntime` (~4.6 ms) — present on
46
+ * glibc, absent on musl. It needs no filesystem, which is why it is the
47
+ * fallback for a container that hides `/proc`. Verified on all four
48
+ * combinations: node+glibc `"2.43"`, bun+glibc `"2.43"`, node+musl absent,
49
+ * bun+musl absent (node:22-alpine).
50
+ *
51
+ * Non-Linux returns `unknown` and NOT `musl`: the report header omits
52
+ * `glibcVersionRuntime` on macOS and Windows too, so reading its absence as
53
+ * musl would mislabel every mac. musl is a Linux question.
54
+ *
55
+ * Inconclusive is `unknown`, and `unknown` changes nothing — glibc is upstream's
56
+ * default and the overwhelmingly common case, so a failed probe must degrade to
57
+ * today's behaviour rather than to a guess.
58
+ */
59
+ export declare function detectLibcFamily(probe: {
60
+ platform: string;
61
+ readProcSelfMaps: () => string | undefined;
62
+ readReportLibc: () => LibcFamily;
63
+ }): LibcFamily;
64
+ /** The real probe. Every leg is wrapped: an inconclusive read must not throw. */
65
+ export declare function nodeLibcFamily(): LibcFamily;
66
+ /**
67
+ * GAIA-326 (A1) — make the cockpit start on musl with no manual step.
68
+ *
69
+ * `@opentui/core@0.5.1` selects its native platform package from
70
+ * `process.env.OPENTUI_LIBC` and DEFAULTS TO GLIBC — it runs no probe of its
71
+ * own. Both prebuilts are always on disk (npm and pnpm filter optional deps by
72
+ * `os`/`cpu`, never by libc), so on Alpine the wrong one loads and the first
73
+ * frame dies with `Error loading shared library ld-linux-x86-64.so.2 (needed by
74
+ * libopentui.so)`. Requiring the user to export the variable IS a manual step,
75
+ * so A1 is only met once we set it.
76
+ *
77
+ * An explicit value always wins: an operator who exports `OPENTUI_LIBC=glibc`
78
+ * on a musl box is doing something deliberate, and a probe must not overrule
79
+ * them. Because a set value short-circuits BEFORE `detect` is called, the Bun
80
+ * child — which inherits this env — never pays for the probe a second time.
81
+ */
82
+ export declare function applyOpentuiLibc(env: NodeJS.ProcessEnv, detect?: () => LibcFamily): void;
83
+ /** Replace this process with the same command under Bun. Never returns. */
84
+ export declare function reExecUnderBun(bin: string, argv: string[]): never;
@@ -0,0 +1,149 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join } from 'node:path';
5
+ /**
6
+ * GAIA-326 — `@opentui/core` reaches its Zig core through FFI, which Node does
7
+ * not expose before 26.1 (and then only behind `--experimental-ffi`). So the
8
+ * cockpit runs under the `bun` that `@gaia-ai/addon-gaia-ui` installs.
9
+ *
10
+ * `process.versions.bun` is the loop guard: it is defined under Bun and nowhere
11
+ * else, so a re-exec can never recurse and no sentinel variable is introduced.
12
+ * `--print-config` deliberately stays on Node — it is what a human runs when the
13
+ * cockpit will not start, and a diagnostic that needs the thing it diagnoses is
14
+ * worth nothing.
15
+ *
16
+ * `injectedRenderer` is the same argument one step further: the runtime is
17
+ * needed by the DYNAMICALLY-resolved `@gaia-ai/addon-gaia-ui`, so a caller that
18
+ * supplies its own `runUi` never reaches OpenTUI and must not be re-execed.
19
+ */
20
+ export function shouldReExecUnderBun(input) {
21
+ if (input.printConfig)
22
+ return false;
23
+ if (input.injectedRenderer)
24
+ return false;
25
+ if (input.bunVersion !== undefined)
26
+ return false;
27
+ if (input.noBun !== undefined && input.noBun !== '')
28
+ return false;
29
+ return true;
30
+ }
31
+ /**
32
+ * Resolve the `bun` executable from the renderer addon's own base — the same
33
+ * dynamic seam `loadGaiaUiModule` resolves the renderer through, so the one
34
+ * dynamic edge in this package stays one edge.
35
+ *
36
+ * The manifest is read with a plain `readFileSync` rather than through the
37
+ * resolver: `createRequire(...).resolve()` gives an absolute path, and reading a
38
+ * known path needs no module system. `bun`'s manifest is resolved by name
39
+ * (`bun/package.json`) because the launcher's location is the thing we are
40
+ * looking up; its `bin` entry is a relative path from the manifest's directory.
41
+ */
42
+ export function resolveBunBinary(bases) {
43
+ for (const base of bases) {
44
+ try {
45
+ const addon = createRequire(base).resolve('@gaia-ai/addon-gaia-ui');
46
+ const manifest = createRequire(addon).resolve('bun/package.json');
47
+ const { bin } = JSON.parse(readFileSync(manifest, 'utf8'));
48
+ const relative = typeof bin === 'string' ? bin : bin?.bun;
49
+ if (relative)
50
+ return join(dirname(manifest), relative);
51
+ }
52
+ catch {
53
+ // try the next base
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ /**
59
+ * The dynamic linker musl installs. Matched as a path segment ending in `.so`
60
+ * rather than a bare `includes('ld-musl')`, so an unrelated mapped file whose
61
+ * NAME happens to carry the string cannot be mistaken for the interpreter.
62
+ */
63
+ const MUSL_INTERPRETER = /\/ld-musl-[^/\s]*\.so/;
64
+ /**
65
+ * GAIA-326 (A1) — which C library is this host running?
66
+ *
67
+ * Two evidence sources, cheapest first, both injected so the answer never
68
+ * depends on the machine the test runs on:
69
+ *
70
+ * - `/proc/self/maps` (~0.1 ms) names the interpreter the kernel ACTUALLY
71
+ * mapped into this process. That is the strongest signal available: a glibc
72
+ * box with a musl-compat package installed has `/lib/ld-musl-x86_64.so.1` on
73
+ * disk but never maps it, so the `existsSync` shortcut would misread it.
74
+ * - the node report's `header.glibcVersionRuntime` (~4.6 ms) — present on
75
+ * glibc, absent on musl. It needs no filesystem, which is why it is the
76
+ * fallback for a container that hides `/proc`. Verified on all four
77
+ * combinations: node+glibc `"2.43"`, bun+glibc `"2.43"`, node+musl absent,
78
+ * bun+musl absent (node:22-alpine).
79
+ *
80
+ * Non-Linux returns `unknown` and NOT `musl`: the report header omits
81
+ * `glibcVersionRuntime` on macOS and Windows too, so reading its absence as
82
+ * musl would mislabel every mac. musl is a Linux question.
83
+ *
84
+ * Inconclusive is `unknown`, and `unknown` changes nothing — glibc is upstream's
85
+ * default and the overwhelmingly common case, so a failed probe must degrade to
86
+ * today's behaviour rather than to a guess.
87
+ */
88
+ export function detectLibcFamily(probe) {
89
+ if (probe.platform !== 'linux')
90
+ return 'unknown';
91
+ const maps = probe.readProcSelfMaps();
92
+ if (maps !== undefined)
93
+ return MUSL_INTERPRETER.test(maps) ? 'musl' : 'glibc';
94
+ return probe.readReportLibc();
95
+ }
96
+ /** The real probe. Every leg is wrapped: an inconclusive read must not throw. */
97
+ export function nodeLibcFamily() {
98
+ return detectLibcFamily({
99
+ platform: process.platform,
100
+ readProcSelfMaps: () => {
101
+ try {
102
+ return readFileSync('/proc/self/maps', 'utf8');
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ },
108
+ readReportLibc: () => {
109
+ try {
110
+ const report = process.report.getReport();
111
+ return report.header?.glibcVersionRuntime ? 'glibc' : 'musl';
112
+ }
113
+ catch {
114
+ return 'unknown';
115
+ }
116
+ },
117
+ });
118
+ }
119
+ /**
120
+ * GAIA-326 (A1) — make the cockpit start on musl with no manual step.
121
+ *
122
+ * `@opentui/core@0.5.1` selects its native platform package from
123
+ * `process.env.OPENTUI_LIBC` and DEFAULTS TO GLIBC — it runs no probe of its
124
+ * own. Both prebuilts are always on disk (npm and pnpm filter optional deps by
125
+ * `os`/`cpu`, never by libc), so on Alpine the wrong one loads and the first
126
+ * frame dies with `Error loading shared library ld-linux-x86-64.so.2 (needed by
127
+ * libopentui.so)`. Requiring the user to export the variable IS a manual step,
128
+ * so A1 is only met once we set it.
129
+ *
130
+ * An explicit value always wins: an operator who exports `OPENTUI_LIBC=glibc`
131
+ * on a musl box is doing something deliberate, and a probe must not overrule
132
+ * them. Because a set value short-circuits BEFORE `detect` is called, the Bun
133
+ * child — which inherits this env — never pays for the probe a second time.
134
+ */
135
+ export function applyOpentuiLibc(env, detect = nodeLibcFamily) {
136
+ const current = env.OPENTUI_LIBC;
137
+ if (current !== undefined && current !== '')
138
+ return;
139
+ if (detect() === 'musl')
140
+ env.OPENTUI_LIBC = 'musl';
141
+ }
142
+ /** Replace this process with the same command under Bun. Never returns. */
143
+ export function reExecUnderBun(bin, argv) {
144
+ const result = spawnSync(bin, argv.slice(1), {
145
+ stdio: 'inherit',
146
+ env: process.env,
147
+ });
148
+ process.exit(result.status ?? 1);
149
+ }
package/dist/src/ui.js CHANGED
@@ -5,6 +5,7 @@ import { herdrAgentHost } from '@gaia-ai/addon-herdr';
5
5
  import { CommandRunner, createLogger, exec, fetchUpdateNotice, loadGaiaConfig, machineContextPath, readMachineContext, resolveCliVersion, setDefaultCommandRunner, } from '@gaia-ai/core';
6
6
  import { buildProgram as buildDropshProgram, resolveAuth } from 'dropsh';
7
7
  import { createHttpClient, createJsonApiClient } from 'dropsh/plugin';
8
+ import { applyOpentuiLibc, reExecUnderBun, resolveBunBinary, shouldReExecUnderBun, } from './bun-runtime.js';
8
9
  import { parseTarget, resolveHere, UI_VIEW_MODES, } from './route.js';
9
10
  import { resolveAgentCommand, resolveProjectRooting, } from './ui-home.js';
10
11
  /** The auth profile `gaia ui` writes as. Reads stay on `session`. */
@@ -173,6 +174,38 @@ export async function cmdUi(deps = {}) {
173
174
  (deps.stderr ?? ((line) => process.stderr.write(line)))(`${message}\n`);
174
175
  process.exitCode = 1;
175
176
  };
177
+ // GAIA-326 (A1) — teach OpenTUI which libc this host runs BEFORE anything
178
+ // else. It is set on THIS process rather than only on the re-exec's `env`
179
+ // argument, and that placement is the whole point: `reExecUnderBun` passes
180
+ // `env: process.env`, so the Bun child inherits it for free, while the three
181
+ // paths that never re-exec (already under Bun, `$GAIA_UI_NO_BUN` on Node 26 +
182
+ // --experimental-ffi, an injected `runUi`) load `@opentui/core` in THIS
183
+ // process and would otherwise get the glibc default on Alpine. `@opentui/core`
184
+ // reads the variable when it resolves its native asset — strictly later than
185
+ // here, since the renderer is a dynamic import — so one assignment covers
186
+ // every path. On glibc it is a no-op.
187
+ applyOpentuiLibc(process.env);
188
+ // GAIA-326 — the renderer needs Bun's FFI. Re-exec here rather than in a bin
189
+ // shim: there are three shims, the host already owns the value-aware argv
190
+ // pre-scan that says `ui` was invoked, and `--print-config` must keep working
191
+ // without Bun. This sits after `fail` is declared (the only ordering the
192
+ // named-error path allows) and still before every config load and the whole
193
+ // deep-link ladder, so nothing is resolved twice across the two processes.
194
+ if (shouldReExecUnderBun({
195
+ bunVersion: process.versions.bun,
196
+ noBun: process.env.GAIA_UI_NO_BUN,
197
+ printConfig: Boolean(deps.printConfig),
198
+ injectedRenderer: deps.runUi !== undefined,
199
+ })) {
200
+ const bin = resolveBunBinary(host.resolveBases);
201
+ if (bin === undefined) {
202
+ fail('gaia ui: could not resolve the bun runtime. `@gaia-ai/addon-gaia-ui` ' +
203
+ 'depends on it; reinstall, or set $GAIA_UI_NO_BUN=1 to run on Node ' +
204
+ '26+ with --experimental-ffi.');
205
+ return;
206
+ }
207
+ reExecUnderBun(bin, process.argv);
208
+ }
176
209
  // GAIA-223: build the deep-link route FIRST — argv parsing, the conflict rule
177
210
  // and the whole `--here` ladder are offline, so every one of their failures
178
211
  // costs no config load and no network round-trip, and never reaches the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "GAIA project-first cockpit: the `gaia ui` command plugin (renderer resolved dynamically).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,8 +26,8 @@
26
26
  "directory": "gaia-cli/ui"
27
27
  },
28
28
  "dependencies": {
29
- "@gaia-ai/core": "^0.8.0",
30
- "@gaia-ai/addon-herdr": "^0.8.0",
29
+ "@gaia-ai/core": "^0.9.1",
30
+ "@gaia-ai/addon-herdr": "^0.9.1",
31
31
  "commander": "^12.1.0",
32
32
  "dropsh": "^0.5.8"
33
33
  }