@fnclaude/cli 1.0.0 → 1.1.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/bin/fnc.js +79 -14
- package/bin/preflight.js +66 -0
- package/package.json +1 -1
- package/src/main.ts +36 -1
package/bin/fnc.js
CHANGED
|
@@ -1,24 +1,89 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
// fnclaude entry point —
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @fnclaude/cli entry point — owns the Node→Bun preflight and re-execs
|
|
3
|
+
// itself under Bun when needed.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Why Node-shebang for a Bun cli: when `npm i -g @fnclaude/cli` puts
|
|
6
|
+
// `fnc` on PATH, the user's shell invokes it under whichever runtime npm
|
|
7
|
+
// happened to link against — typically Node, since npm itself runs under
|
|
8
|
+
// Node. We must therefore be loadable under Node, do our own runtime
|
|
9
|
+
// check, and re-exec under Bun ourselves. This shim used to live in the
|
|
10
|
+
// umbrella package (`fnclaude`), which meant standalone installs of
|
|
11
|
+
// `@fnclaude/cli` skipped it and silently degraded (and, on the Bun
|
|
12
|
+
// side, hit the `--`-stripping bug). Owning the preflight here is the
|
|
13
|
+
// single source of truth.
|
|
9
14
|
//
|
|
10
15
|
// Module resolution: dist/main.js is what npm-installed users execute;
|
|
11
|
-
// local devs running from source use Bun's TS support to import
|
|
12
|
-
// directly. We attempt dist first and fall back to src so
|
|
13
|
-
// are first-class without a separate dev shim.
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
+
// local devs running from source use Bun's TS support to import
|
|
17
|
+
// src/main.ts directly. We attempt dist first and fall back to src so
|
|
18
|
+
// both workflows are first-class without a separate dev shim.
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
16
22
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
23
|
+
import { decide, defaultLookupBun } from './preflight.js';
|
|
17
24
|
|
|
18
|
-
const
|
|
25
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
26
|
+
const here = dirname(selfPath);
|
|
27
|
+
|
|
28
|
+
// Short-circuit --version BEFORE the Bun preflight. Reading package.json
|
|
29
|
+
// is pure Node-stdlib work and doesn't need Bun — friendlier when
|
|
30
|
+
// someone is diagnosing a broken install without Bun on PATH.
|
|
31
|
+
const argv = process.argv.slice(2);
|
|
32
|
+
if (argv.includes('--version') || argv.includes('-v')) {
|
|
33
|
+
const pkg = JSON.parse(
|
|
34
|
+
readFileSync(join(here, '..', 'package.json'), 'utf8'),
|
|
35
|
+
);
|
|
36
|
+
process.stdout.write(`fnclaude ${pkg.version}\n`);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const decision = decide({
|
|
41
|
+
hasBun: typeof globalThis.Bun !== 'undefined',
|
|
42
|
+
lookupBun: defaultLookupBun,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (decision.kind === 'error') {
|
|
46
|
+
process.stderr.write(`${decision.message}\n`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (decision.kind === 'reexec') {
|
|
51
|
+
// Re-launch ourselves under Bun. `spawnSync` with stdio:'inherit'
|
|
52
|
+
// forwards streams transparently; we propagate the child's exit code
|
|
53
|
+
// so the OS / parent process sees the same status it would have seen
|
|
54
|
+
// had Bun been the launcher all along.
|
|
55
|
+
//
|
|
56
|
+
// Argv-via-env: Bun strips the first `--` from a script's argv,
|
|
57
|
+
// regardless of where it appears (script invocation, `bun --`, `bun
|
|
58
|
+
// run`, shebang). Confirmed empirically. So passing the user's args
|
|
59
|
+
// as Bun-script argv would silently mangle `fnc -- "prompt"` into
|
|
60
|
+
// `fnc "prompt"` — the cli then treats the prompt as a cwd
|
|
61
|
+
// positional, the resolver fires, and 8 GitHub orgs 404 in series
|
|
62
|
+
// before the user gets a misleading "could not resolve" error. We
|
|
63
|
+
// sidestep by serialising the user's args into FNC_ARGS_JSON; main()
|
|
64
|
+
// reads from there when present (and deletes the env var to avoid
|
|
65
|
+
// leaking to its own children).
|
|
66
|
+
const r = spawnSync(decision.bun, [selfPath], {
|
|
67
|
+
stdio: 'inherit',
|
|
68
|
+
env: {
|
|
69
|
+
...process.env,
|
|
70
|
+
FNC_ARGS_JSON: JSON.stringify(argv),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (r.error) {
|
|
74
|
+
// ENOENT shouldn't reach here — lookupBun confirmed bun is reachable
|
|
75
|
+
// — but if something else broke (EACCES, ETXTBSY), surface it
|
|
76
|
+
// instead of swallowing.
|
|
77
|
+
process.stderr.write(`fnclaude: failed to re-exec under bun: ${r.error.message}\n`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
process.exit(r.status ?? 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// decision.kind === 'run' — we're already under Bun. Import main() and
|
|
84
|
+
// dispatch.
|
|
19
85
|
const distMain = resolve(here, '..', 'dist', 'main.js');
|
|
20
86
|
const srcMain = resolve(here, '..', 'src', 'main.ts');
|
|
21
|
-
|
|
22
87
|
const target = existsSync(distMain) ? distMain : srcMain;
|
|
23
88
|
const { main } = await import(pathToFileURL(target).href);
|
|
24
89
|
|
package/bin/preflight.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Preflight for the cli `fnc` bin shim — decide whether to proceed,
|
|
2
|
+
// re-exec under Bun, or hard-error out with a directive message.
|
|
3
|
+
//
|
|
4
|
+
// The cli relies on Bun-only globals (`Bun.spawn`, `Bun.TOML.parse`,
|
|
5
|
+
// `Bun.which`, `process.execve`). When the bin is invoked under Node
|
|
6
|
+
// (typically because `npm i -g @fnclaude/cli` puts it on PATH and the
|
|
7
|
+
// user runs `fnc`), those Bun calls fail silently — `Bun.which` returns
|
|
8
|
+
// `undefined`, `Bun.TOML.parse` throws "Bun is not defined", etc. The
|
|
9
|
+
// cli then degrades into a "claude not found" / config-broken state
|
|
10
|
+
// that LOOKS like a normal failure but is actually a runtime mismatch.
|
|
11
|
+
//
|
|
12
|
+
// This preflight runs first and either re-execs under Bun (if `bun` is
|
|
13
|
+
// on PATH) or prints a directive error and exits non-zero — never the
|
|
14
|
+
// silent-degradation path.
|
|
15
|
+
//
|
|
16
|
+
// Extracted from the shim itself to keep it unit-testable: the decision
|
|
17
|
+
// function takes injected seams (typeof-Bun probe + PATH lookup) and
|
|
18
|
+
// returns a discriminated union; the shim handles the dispatch.
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{ kind: 'run' }
|
|
24
|
+
* | { kind: 'reexec', bun: string }
|
|
25
|
+
* | { kind: 'error', message: string }
|
|
26
|
+
* } PreflightDecision
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Decide what the shim should do.
|
|
31
|
+
*
|
|
32
|
+
* @param {{ hasBun: boolean, lookupBun: () => string | null }} seams
|
|
33
|
+
* @returns {PreflightDecision}
|
|
34
|
+
*/
|
|
35
|
+
export function decide({ hasBun, lookupBun }) {
|
|
36
|
+
if (hasBun) return { kind: 'run' };
|
|
37
|
+
const bun = lookupBun();
|
|
38
|
+
if (bun !== null) return { kind: 'reexec', bun };
|
|
39
|
+
return {
|
|
40
|
+
kind: 'error',
|
|
41
|
+
message:
|
|
42
|
+
'fnclaude: this CLI requires Bun (https://bun.sh) to run.\n' +
|
|
43
|
+
' The shim was invoked under Node, but the CLI depends on Bun-only\n' +
|
|
44
|
+
' globals (Bun.spawn, Bun.TOML.parse, process.execve). Install Bun\n' +
|
|
45
|
+
' and re-run `fnc`, or invoke the script via `bun ...` directly.',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Default PATH lookup for `bun`. Returns the literal string `'bun'` on
|
|
51
|
+
* success (the OS will resolve it via PATH at spawn time), or null when
|
|
52
|
+
* `bun` is not reachable.
|
|
53
|
+
*
|
|
54
|
+
* Implementation note: `spawnSync('bun', ['--version'])` is the simplest
|
|
55
|
+
* cross-platform probe. ENOENT comes back as `result.error.code`, and a
|
|
56
|
+
* present-but-broken bun returns non-zero status. Both are treated as
|
|
57
|
+
* "not usable."
|
|
58
|
+
*
|
|
59
|
+
* @returns {string | null}
|
|
60
|
+
*/
|
|
61
|
+
export function defaultLookupBun() {
|
|
62
|
+
const r = spawnSync('bun', ['--version'], { stdio: 'ignore' });
|
|
63
|
+
if (r.error) return null;
|
|
64
|
+
if (r.status !== 0) return null;
|
|
65
|
+
return 'bun';
|
|
66
|
+
}
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -145,6 +145,41 @@ export interface RunDeps {
|
|
|
145
145
|
data?: RunConfig;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Read the user's argv, preferring `FNC_ARGS_JSON` over `process.argv`.
|
|
150
|
+
*
|
|
151
|
+
* The umbrella shim (packages/fnclaude/bin/fnc.js) sets `FNC_ARGS_JSON`
|
|
152
|
+
* on the env it spawns Bun with, because Bun strips the first `--`
|
|
153
|
+
* from a script's argv (confirmed empirically across `bun script.js`,
|
|
154
|
+
* `bun --`, `bun run`, and shebang invocations). Passing user args via
|
|
155
|
+
* the env var sidesteps that — Bun never sees them as its own argv.
|
|
156
|
+
*
|
|
157
|
+
* We DELETE the var after consumption so any child processes the cli
|
|
158
|
+
* spawns (claude, gh, the relaunch chain) don't inherit a stale value.
|
|
159
|
+
*
|
|
160
|
+
* Defensive: malformed or wrong-shape values fall through to
|
|
161
|
+
* `process.argv.slice(2)` rather than throwing — a corrupted env var
|
|
162
|
+
* shouldn't break the cli, just degrade to the pre-fix behaviour.
|
|
163
|
+
*/
|
|
164
|
+
function readArgvFromEnvOrProcess(): readonly string[] {
|
|
165
|
+
const envArgs = process.env.FNC_ARGS_JSON;
|
|
166
|
+
if (envArgs !== undefined) {
|
|
167
|
+
delete process.env.FNC_ARGS_JSON;
|
|
168
|
+
try {
|
|
169
|
+
const parsed: unknown = JSON.parse(envArgs);
|
|
170
|
+
if (
|
|
171
|
+
Array.isArray(parsed) &&
|
|
172
|
+
parsed.every((x): x is string => typeof x === 'string')
|
|
173
|
+
) {
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// Fall through to process.argv.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return process.argv.slice(2);
|
|
181
|
+
}
|
|
182
|
+
|
|
148
183
|
function lookupClaudeFromPath(name: string): string | undefined {
|
|
149
184
|
// Bun's PATH lookup: Bun.which() returns null when not found. Coerce to
|
|
150
185
|
// undefined to keep the absent-value sentinel consistent with the rest of
|
|
@@ -174,7 +209,7 @@ export async function run(deps: RunDeps = {}): Promise<number> {
|
|
|
174
209
|
const io = deps.io ?? {};
|
|
175
210
|
const data = deps.data ?? {};
|
|
176
211
|
|
|
177
|
-
const argv = io.argv ??
|
|
212
|
+
const argv = io.argv ?? readArgvFromEnvOrProcess();
|
|
178
213
|
const stdout = io.stdout ?? process.stdout;
|
|
179
214
|
const stderr = io.stderr ?? process.stderr;
|
|
180
215
|
const home = io.home ?? process.env.HOME ?? homedir();
|