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
69 changes: 65 additions & 4 deletions src/cli/FrodoCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
updateProgressIndicator,
verboseMessage,
} from '../utils/Console.js';
import { getEntityPicks } from '../utils/interactive/EntityPickers.js';
import {
escapableSelect,
ESCAPE,
Expand All @@ -36,6 +37,7 @@ import {
deferOneOfGroups,
getOneOfGroups,
resolveInteractiveInputs,
setActiveActionCommand,
type OptionRequirementSpec,
} from './ResolveInteractiveInputs.js';

Expand Down Expand Up @@ -2296,6 +2298,40 @@ function splitPositionalArgs(
return { positional };
}

/**
* Options the bare-stub sub-command picker must not swallow -- when one
* of these is present the interception falls through to commander's own
* parse:
*
* - the help family and version make commander act and EXIT where they
* appear, and the picker replaces parseOptions wholesale, so
* intercepting around them would never fire them (`frodo -v` and
* `frodo -h` on a TTY would show the picker instead of their own
* output);
* - `--no-prompt` is the explicit prompting opt-out -- showing a prompt
* against it would defeat its meaning (its value is only read during
* parseOptions, which the picker bypasses, and in a stub's bare
* invocation no preAction runs to set the gate).
*
* Other pass-down options (--verbose, --debug, ...) keep the picker and
* re-parse in the picked sub-command's context as designed.
*/
const BARE_PICKER_BLOCKING_OPTION_TOKENS = new Set([
'-h',
'--help',
HELP_MORE_FLAG,
HELP_MORE_SHORT_FLAG,
HELP_ALL_FLAG,
HELP_ALL_SHORT_FLAG,
'-v',
'--version',
'--no-prompt',
]);

function isBarePickerBlockingOptionToken(token: string): boolean {
return BARE_PICKER_BLOCKING_OPTION_TOKENS.has(token);
}

/**
* commander's own _parseCommand implementation, captured from the
* Command.prototype ONCE at module load -- before any subclass shadows
Expand Down Expand Up @@ -2437,6 +2473,7 @@ export class FrodoStubCommand extends Command {
if (!(actionCommand instanceof FrodoCommand)) return;
if (resolvedActionCommands.has(actionCommand)) return;
resolvedActionCommands.add(actionCommand);
setActiveActionCommand(actionCommand);
await resolveInteractiveInputs(actionCommand);
});
}
Expand Down Expand Up @@ -2657,7 +2694,15 @@ export class FrodoStubCommand extends Command {
if (
self._actionHandler === null &&
positional.length === 0 &&
this.commands.length > 0
this.commands.length > 0 &&
// Root-exiting options (help family, version) must reach
// commander's own parse: the picker replaces parseOptions
// wholesale, so intercepting around them would turn `frodo -v` and
// `frodo -h` into the sub-command picker instead of their own
// output. Pass-down options (--verbose, --no-prompt, ...) don't
// exit and re-parse in the picked sub-command's context, so they
// keep the picker.
!unknown.some(isBarePickerBlockingOptionToken)
) {
return this.pickAndDispatchSubCommand(unknown);
}
Expand Down Expand Up @@ -2741,6 +2786,12 @@ class FrodoStubHelp extends Help {
'help-all': 2,
};

/**
* The command currently being rendered (set by formatHelp). The entity
* pick annotation in optionDescription reads the pick registry off it.
*/
private renderedCommand?: Command;

/**
* Custom help renderer that extends Commander output with deployment-aware
* inline sub-sections for options and subcommands.
Expand All @@ -2753,6 +2804,10 @@ class FrodoStubHelp extends Help {
* @returns Fully rendered help text for the command.
*/
override formatHelp(cmd: Command, helper: Help): string {
// optionDescription is called from inside formatHelp with no command
// context of its own -- remember which command is being rendered so
// the entity-pick annotation can consult that command's registry.
this.renderedCommand = cmd;
const termWidth = helper.padWidth(cmd, helper);
const helpWidth = helper.helpWidth ?? 80;

Expand Down Expand Up @@ -2965,9 +3020,15 @@ class FrodoStubHelp extends Help {
super.optionDescription(option),
option
);
return option.mandatory
? `${description} (prompts if missing)`
: description;
// "(pick from <kind>)": the option has an entity picker registered
// (deferEntityPicks), so an interactive session offers a dropdown of
// the live system's entities for it.
const pick = (
this.renderedCommand ? getEntityPicks(this.renderedCommand) : []
).find((candidate) => candidate.name === option.attributeName());
const pickSuffix = pick ? ` (pick from ${pick.kind})` : '';
const mandatorySuffix = option.mandatory ? ' (prompts if missing)' : '';
return `${description}${pickSuffix}${mandatorySuffix}`;
}

/**
Expand Down
15 changes: 15 additions & 0 deletions src/cli/ResolveInteractiveInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,21 @@ import {
promptInput,
promptPassword,
} from '../utils/interactive/PromptGate';
import { noteRealmProvenance } from '../utils/interactive/RealmPickers';
import { guidedModeEnabled } from '../utils/SettingsStore';

const { getConnectionProfilesPath, getConnectionProfileByHost } = frodo.conn;

// The active-action-command registry lives in its own zero-import module
// (ActiveActionCommandState) so ops-layer importers (AuthenticateOps's
// post-auth realm picker) don't drag this file's frodo-lib-importing
// graph into tests that mock frodo-lib wholesale. Re-exported here for
// the resolution pass's own use.
export {
setActiveActionCommand,
getActiveActionCommand,
} from '../utils/interactive/ActiveActionCommandState';

/**
* A mandatory-option violation that commander's parse recorded instead of
* throwing on. Lives here rather than on the command instance so the
Expand Down Expand Up @@ -198,6 +209,10 @@ export async function resolveInteractiveInputs(
// action's getTokens may still fail on auth, exactly as before).
// Either step bailing out falls back to the pre-Phase-1 error
// semantics.
// The realm provenance MUST be noted before any prompt: frodo-lib maps
// the sentinel default to the deployment type's realm during auth, so
// "user chose" is only distinguishable from "mapped default" here.
noteRealmProvenance();
const connectionResolved = await resolveConnectionArgs(command);
const mandatoryResolved = connectionResolved
? await resolveMandatoryOptions(command)
Expand Down
22 changes: 22 additions & 0 deletions src/ops/AuthenticateOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
} from '@rockcarver/frodo-lib';
import open from 'open';
import { printError, printMessage, verboseMessage } from '../utils/Console';
// The active-command registry, not ResolveInteractiveInputs itself: that
// file's graph reaches the frodo-lib-importing interactive prompts, which
// breaks tests that mock frodo-lib wholesale (see ActiveActionCommandState).
import { getActiveActionCommand } from '../utils/interactive/ActiveActionCommandState';
import { canPrompt } from '../utils/interactive/PromptGateState';
import { question } from '../utils/Prompt';

const { getTokens: _getTokens, getTokensInteractive: _getTokensInteractive } =
Expand Down Expand Up @@ -198,6 +203,23 @@ export async function getTokens(
state.getRealm() ? state.getRealm() : 'root'
}] as ${tokens.subject}`
);
// Post-auth realm picker (Phase 3): offers the target system's realms
// when the command has a realm argument, the user left it at its
// default, and the system has more than one realm. Never throws; a
// declined offer or an unlistable realm set keeps the default. The
// active command is recorded by the resolution pass (preAction), so
// commands that bypass it (login/conn's own stub actions) naturally
// never see a picker.
//
// Gated on canPrompt() and lazy-imported: this module must stay
// loadable in tests that mock frodo-lib wholesale (the picker's graph
// reaches ColorTheme's `theme` import, which such mocks don't carry),
// and a non-interactive session never needs the picker's machinery.
if (canPrompt()) {
const { resolveRealmPick } =
await import('../utils/interactive/RealmPickers');
await resolveRealmPick(getActiveActionCommand());
}
return true;
} catch (error) {
printError(error);
Expand Down
31 changes: 30 additions & 1 deletion src/ops/RealmOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,42 @@ const {
getWorkingDirectory,
} = frodo.utils;

/**
* Per-invocation cache of the realm list. The realm picker (Phase 3) reads
* realms right after auth, and commands like `frodo realm list` read them
* again moments later -- two HTTP calls to the same endpoint that would
* (a) double the latency and (b) break Polly's order-matched replay in e2e
* recordings, which have one entry for the endpoint. Sharing one memoized
* read keeps both callers on a single fetch.
*/
let realmsCache: Awaited<ReturnType<typeof readRealms>> | undefined;

/**
* Reads all realms, memoized for the remainder of this invocation. Callers
* that need fresh data (import/export mutations) call frodo.realm
* .readRealms directly.
*/
export async function readRealmsCached() {
if (!realmsCache) {
realmsCache = await readRealms();
}
return realmsCache;
}

/**
* Clears the memoized realm list (test hygiene between invocations).
*/
export function resetRealmsCacheForTests(): void {
realmsCache = undefined;
}

/**
* List realms
* @param {boolean} long Long list format with details
*/
export async function listRealms(long = false) {
try {
const realms = await readRealms();
const realms = await readRealmsCached();
if (long) {
const table = createTable([
c.heading('Id'),
Expand Down
40 changes: 40 additions & 0 deletions src/utils/interactive/ActiveActionCommandState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* The active action command's registry, split from
* `ResolveInteractiveInputs.ts` the same way PromptGateState is split from
* PromptGate: a zero-import state module that heavier layers (ops/ --
* specifically AuthenticateOps's post-auth realm picker) can import
* without dragging the resolution pass's graph along.
*
* That matters for tests that mock `@rockcarver/frodo-lib` wholesale
* (test/unit/mcp-server-ops.test.js): the mock's namespace only carries
* what its graph actually loads, so any ops->cli edge pulling in the
* frodo-lib-importing interactive prompts (ColorTheme's `theme` import)
* fails the graph walk with "does not provide an export named 'theme'".
* Importing this leaf module instead keeps that edge dependency-free.
*/

import type { Command } from 'commander';

/**
* The leaf command whose action is about to run, recorded by the
* resolution pass. Post-auth hooks that have no command reference of
* their own (the getTokens wrapper consults the realm picker, which
* needs the command to check for a realm argument) read this instead of
* threading the command through every call site.
*/
let activeActionCommand: Command | undefined;

/**
* Records the leaf command about to execute (and clears it, for test
* hygiene between invocations).
*/
export function setActiveActionCommand(command: Command | undefined): void {
activeActionCommand = command;
}

/**
* The leaf command recorded by the resolution pass, if any.
*/
export function getActiveActionCommand(): Command | undefined {
return activeActionCommand;
}
75 changes: 75 additions & 0 deletions src/utils/interactive/RealmPickers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it } from '@jest/globals';
import { state } from '@rockcarver/frodo-lib';
import { Command } from 'commander';
import {
noteRealmProvenance,
resetRealmPickerStateForTests,
resolveRealmPick,
} from './RealmPickers';

// resolveRealmPick is gated by canPrompt(), and the jest harness is never
// a TTY, so every picker path collapses to the no-op result -- which pins
// the non-interactive preservation contract: outside the gate nothing is
// read and no realm is written. The interactive render is verified by PTY
// probes against the built binary (2-realm mock tenant: picker shows,
// pick completes, command proceeds).
function makeCommand(withRealmArg: boolean): Command {
const command = new Command('frodo realm list');
if (withRealmArg) {
command.argument('[realm]', 'Realm.').action(async () => {});
}
return command;
}

afterEach(() => {
resetRealmPickerStateForTests();
delete process.env.FRODO_TEST;
delete process.env.FRODO_NO_PROMPT;
});

describe('resolveRealmPick (non-interactive preservation)', () => {
it('is a no-op under FRODO_TEST=1 (gate refused): no read, no write', async () => {
process.env.FRODO_TEST = '1';
state.setRealm('__default__realm__');
noteRealmProvenance();
await resolveRealmPick(makeCommand(true));
expect(state.getRealm()).toBe('__default__realm__');
});

it('is a no-op when the command has no realm argument', async () => {
state.setRealm('__default__realm__');
noteRealmProvenance();
await resolveRealmPick(makeCommand(false));
expect(state.getRealm()).toBe('__default__realm__');
});

it('is a no-op when the user specified a realm (provenance noted)', async () => {
state.setRealm('bravo');
noteRealmProvenance();
await resolveRealmPick(makeCommand(true));
expect(state.getRealm()).toBe('bravo');
});

it('is a no-op without a prior noteRealmProvenance (defensive)', async () => {
state.setRealm('bravo');
await resolveRealmPick(makeCommand(true));
expect(state.getRealm()).toBe('bravo');
});
});

describe('noteRealmProvenance', () => {
it('treats the sentinel default as "not user-specified"', () => {
state.setRealm('__default__realm__');
noteRealmProvenance();
// The provenance flag is module-private; its effect is observable via
// resolveRealmPick's no-op-under-gate behavior only. This assertion
// pins the sentinel value the helper compares against.
expect(state.getRealm()).toBe('__default__realm__');
});

it('treats a concrete realm as user-specified', () => {
state.setRealm('/parent/child');
noteRealmProvenance();
expect(state.getRealm()).toBe('/parent/child');
});
});
Loading
Loading