Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/BUILD-ENV.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,52 @@ loaded, then threw `SyntaxError: Unexpected token ','`. The workaround
- `deps.neverBundle` lists every devDependency. tsdown (like tsup before it)
otherwise bundles devDependencies — which is why our devDependencies are
effectively the runtime dependency list of the binary.

#### 2.2.1 The dependency-placement rule (read before adding any package)

The published package is **zero-dep by design**: package.json's
`dependencies` field stays empty, and everything the binary needs at
runtime lives in **`devDependencies`** and is inlined into the bundle.
This is what keeps `npm install` weight minimal, keeps
`npm audit --omit=dev` empty (the audit surface we drove to zero in the
zero-audit push and want to keep empty), and is what makes the SEA binary
self-contained.

Bundling is driven entirely by which bucket a package sits in — not by
any explicit bundle list:

| Bucket | tsdown behavior (tsup had the same default) |
| ----------------- | ------------------------------------------------------------------------------------ |
| `devDependencies` | **inlined** into `dist/*.cjs` / SEA bundle |
| `dependencies` | **externalized** to a bare `require()` — unless forced back with `deps.alwaysBundle` |

So the npm-conventional instinct — "imported at runtime ⇒ declare as
`dependencies`" — is **wrong in this repo**. It happened with the
interactive-prompt feature (PR #772): moving the `@inquirer/*` packages
to `dependencies` made tsdown externalize them, and the SEA binary (a
single embedded file with no `node_modules` next to it) failed at runtime
with `ERR_UNKNOWN_BUILTIN_MODULE` — caught by CI's SEA e2e leg only,
because a stale locally built binary still contained the old inlined
code. Declared deps would also have reopened the npm audit surface and
made consumers install three packages whose code is inlined anyway.

**Rule: a package imported by shipped code goes in `devDependencies`,
and nothing ever goes in `dependencies`.** If a package genuinely cannot
be bundled, that is a design problem to raise — not something to paper
over with `alwaysBundle`.

Verification one-liner after any dependency-graph change (both counts
must be 0):

```console
grep -c 'require("@' dist/app.cjs dist-sea/app.cjs
```

(Non-`node:` external requires in a fresh bundle mean something got
externalized; the SEA build is the one that breaks at runtime. Build the
binary fresh first — `npm run build:binary` — a stale `dist-sea/` masks
the problem exactly the way it did in #772.)

- JSON imports (`src/ops/templates/*.json`) are inlined at build time; no
runtime file reads, nothing to ship as assets.
- `define: { __CLI_BUILD_TIMESTAMP__ }` stamps the build time that `-v` prints.
Expand Down Expand Up @@ -361,4 +407,5 @@ pkg→SEA migration (it builds `dist/` from source).
| 2026-10-05 | npm dev-dependency majors (Batch 2, merged sequentially with rebase discipline): @types/node 26, chokidar 5, uuid 14, commander 15 (SEA-validated on main before merge), properties-reader 3 (`propertiesReader({sourceFile})` object form; `.each` callback value widened to `Value` → env-file values coerced with `String()`) | #740, #725, #720, #739, #738 |
| 2026-10-05 | frodo-lib pinned to exact versions (4.11.1 → 4.12.0-1) after each lib release; lib pin is deliberate (exact, not caret) so cli CI always validates against the version it will ship with | #754 |
| 2026-10-05 | Deep-import type migration: all 110 `@rockcarver/frodo-lib/types/*` statements (62 files) migrated to root-entry imports with inline `type` qualifiers — removes the node10-moduleResolution dependency (`./types/*` subpath is deprecated in TS 6, removed in TS 7) and gives IDEs the standard single-entry type surface. Requires frodo-lib ≥ 4.12 (#687 root type exports) | #755 |
| 2026-10-10 | Dependency-placement rule documented (§2.2.1): all bundled runtime packages live in `devDependencies` (tsdown inlines those; `dependencies` entries are externalized and break the SEA binary + reopen the npm audit surface). The `@inquirer/*` move to `dependencies` in #772 was reverted on this rule — `dependencies` stays empty by design. tsdown's `deps.alwaysBundle` exists as the escape hatch but should never be needed here. tsup equivalent: `noExternal`/`external` | #772 |
| planned | Polly→nock (library repo) — DEFERRED 2026-10-04 (deep record-harness coupling; revisit on Node 28 or real breakage) | — |
11 changes: 11 additions & 0 deletions docs/CONTRIBUTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ For how the build toolchain works — what each tool does and why, bundler
configuration details, binary packaging and signing — see
[BUILD-ENV.md](BUILD-ENV.md).

> **Adding a package? Read this first.** This CLI ships as a
> zero-dependency package: package.json's `dependencies` field stays
> empty on purpose, and every package imported by shipped code —
> including runtime prompt/library code — goes in **`devDependencies`**.
> The bundler (tsdown; tsup had the same default) inlines
> devDependencies into the bundle and externalizes anything listed under
> `dependencies` — which breaks the SEA binary at runtime (no
> `node_modules` inside the binary) and reopens the npm audit surface we
> deliberately keep empty. Details and the verification one-liner:
> [BUILD-ENV.md §2.2.1](BUILD-ENV.md).

#### Full build (npm bundle + platform binary)

The following command builds the CLI and creates a native SEA binary for
Expand Down
25 changes: 0 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@
"@ianvs/prettier-plugin-sort-imports": "4.7.1",
"@inquirer/confirm": "6.3.2",
"@inquirer/core": "^12.0.1",
"@inquirer/select": "^5.2.3",
"@modelcontextprotocol/client": "^2.0.0",
"@modelcontextprotocol/node": "^2.0.0",
"@modelcontextprotocol/server": "^2.0.0",
Expand Down
88 changes: 88 additions & 0 deletions src/cli/FrodoCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import {
updateProgressIndicator,
verboseMessage,
} from '../utils/Console.js';
import { setNeverPrompt } from '../utils/interactive/PromptGate.js';
import { activatePersistedTheme } from '../utils/ThemeConfig.js';
import {
deferMissingMandatoryOption,
resolveInteractiveInputs,
} from './ResolveInteractiveInputs.js';

// Frodo constants
const constants = frodo.utils.constants;
Expand Down Expand Up @@ -867,6 +872,25 @@ const forceUpdateOption = withHelpGroup(
OptionCategory.Runtime
);

// Opt-out of the interactive gap-fill (INTERACTIVE-COMMANDS-PLAN.md,
// Phase 1): with this flag, missing mandatory options/connection args
// fail with the classic errors instead of prompting, even on a TTY.
// Categorized as Runtime (it is a per-invocation behavior control) and
// also honored process-wide via FRODO_NO_PROMPT (see PromptGate).
// Commander negation semantics: the lone `--no-prompt` flag's attribute
// name is `prompt`, defaults to true (prompting allowed), and passing
// --no-prompt sets it false. Do NOT chain .default(false) here -- that
// would read as "prompting disabled by default" and disable the whole
// feature (caught in review of the first cut).
const promptOption = withHelpGroup(
new Option(
'--no-prompt',
'Never prompt for missing input; fail with the usual errors instead (same as setting FRODO_NO_PROMPT).'
).default(true, 'Prompt for missing input where supported'),
RUNTIME_OPTIONS_HEADING,
OptionCategory.Runtime
);

const insecureOption = withHelpGroup(
new Option(
'-k, --insecure',
Expand Down Expand Up @@ -1001,6 +1025,7 @@ const defaultOpts = [
envOption,
envFileOption,
forceUpdateOption,
promptOption,
];

/**
Expand Down Expand Up @@ -2005,6 +2030,13 @@ export function normalizeExpandedHelpArgv(argv: string[] = process.argv) {
}

const warnedStabilityCommands = new Set<string>();
// Leaf commands whose interactive input resolution already ran (or is
// running) this process. The preAction hook is registered on every
// FrodoCommand in the ancestry and all receive the same actionCommand, so
// without this the resolution would run once per ancestor. Also guards
// against a re-entrant double-run if a hook ever fires twice for the same
// command instance.
const resolvedActionCommands = new WeakSet<Command>();
const warnedStabilityOptions = new Set<string>();

/**
Expand Down Expand Up @@ -2308,8 +2340,52 @@ export class FrodoStubCommand extends Command {
cleanupProgressIndicators();
});

// Async preAction hook: commander's hook chain awaits returned
// promises (_chainOrCall), so the action handler below only starts
// after interactive input resolution finished. The hook is registered
// on every FrodoCommand in the ancestry and all receive the same
// actionCommand, so dedupe on the executing command.
this.hook('preAction', (_thisCommand, actionCommand) => {
enforceStabilityAndWarn(actionCommand);
// Commander negation: `prompt` defaults to true; --no-prompt sets
// it false. Set once per process, before the resolution hook below
// (hook order = registration order).
if (
actionCommand.getOptionValue(promptOption.attributeName()) === false
) {
setNeverPrompt();
}
});

this.hook('preAction', async (_thisCommand, actionCommand) => {
// Only leaf FrodoCommands get the resolution pass. Stub commands'
// own bare actions (conn, settings, login -- all already
// interactive by design) must not run it, or they would
// double-prompt.
if (!(actionCommand instanceof FrodoCommand)) return;
if (resolvedActionCommands.has(actionCommand)) return;
resolvedActionCommands.add(actionCommand);
await resolveInteractiveInputs(actionCommand);
});
}

/**
* Commander calls this during parsing, BEFORE the preAction hook chain,
* when a `makeOptionMandatory()` option is missing. Reimplemented here
* (verbatim from commander's own implementation, whose typings omit the
* method -- so an override below cannot `super` into it) as the shared
* declaration point both subclasses control: stub commands keep this
* verbatim throw (today's behavior, unchanged), and FrodoCommand
* replaces it with deferral (see ResolveInteractiveInputs) so the
* preAction resolution pass can prompt for the value on an interactive
* session, or re-throw this exact error on a non-interactive one. That
* is what makes all existing `makeOptionMandatory()` call sites
* prompt-capable with zero syntax change.
* @param option The mandatory option that is missing.
*/
missingMandatoryOptionValue(option: Option): void {
this.error(`error: required option '${option.flags}' not specified`, {
code: 'commander.missingMandatoryOptionValue',
});
}

Expand Down Expand Up @@ -2744,6 +2820,18 @@ export class FrodoCommand extends FrodoStubCommand {
*/
types: string[];

/**
* Deferred mandatory handling (see FrodoStubCommand's implementation
* for the verbatim-commander default): record the violation instead of
* throwing, so the preAction resolution pass can prompt for the value
* on an interactive session, or re-throw this exact error on a
* non-interactive one.
* @param option The mandatory option that is missing.
*/
missingMandatoryOptionValue(option: Option): void {
deferMissingMandatoryOption(this, option.flags);
}

/**
* Creates a new FrodoCommand instance
* @param name Name of the command
Expand Down
Loading
Loading