diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ab9022..83d578b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. The format ### Added -- [#204](https://github.com/mohanagy/miftah/issues/204) Added guided multi-account Google Search Console onboarding through the CLI and local Console, including an explicit opt-in first-success check that resolves only the selected target, verifies audit/policy/identity boundaries before its one declared read-only probe, and cancels with the requesting Console session. The check trusts only the catalog's reviewed launch envelope and fails closed without launching a process after execution-affecting customization. It creates named profiles with an explicit durable default and separate upstream-owned OAuth state directories per generated configuration file and profile, while continuing to keep browser login, token-cache contents, reauthentication, revocation, and account identity verification outside Miftah. A returning provider-owned account can now be added atomically through the reviewed adapter contract in either CLI or Console, with a fresh isolated state directory, a redacted fail-closed lifecycle record, and no token-cache access. It also adds a separate returning-user path for a simple local STDIO environment credential binding: CLI and Console accept a variable name rather than a secret, require every existing profile to share the same narrow binding, enforce multi-profile safeguards, use guarded audited replacement, and refuse remote HTTP, provider-adapter, OAuth, named-upstream, mixed-profile, duplicate-source, and arbitrary-override configurations. It also adds explicitly acknowledged local STDIO setup and client-entry import: literal executable plus bounded argument array, no shell, no secret-shaped values, no generic-command launch during onboarding, read-only default policy, destructive handling for unknown tools, and Windows-only direct absolute `.exe`/`.com` requirements to avoid a command-shell fallback. Returning CLI users can now review the durable default and fixed non-secret account metadata with `profile list`, and rerun only the selected provider adapter's declared, audited read-only readiness check with `profile test`, receiving a redacted report and nonzero outcome for any non-ready state without an arbitrary tool call, browser handoff, configuration mutation, or provider-cache access. It also adds a guarded CLI and local Console flow for setting or explicitly clearing one existing profile's non-secret description. It preserves all other profile fields, durable default, routing, OAuth bindings, credential and provider-cache ownership; writes through the existing audited replacement and exact-recovery transaction; and returns no configuration bytes or submitted label. +- [#204](https://github.com/mohanagy/miftah/issues/204) Added guided multi-account Google Search Console onboarding through the CLI and local Console, including an explicit opt-in first-success check that resolves only the selected target, verifies audit/policy/identity boundaries before its one declared read-only probe, and cancels with the requesting Console session. The check trusts only the catalog's reviewed launch envelope and fails closed without launching a process after execution-affecting customization. It creates named profiles with an explicit durable default and separate upstream-owned OAuth state directories per generated configuration file and profile, while continuing to keep browser login, token-cache contents, reauthentication, revocation, and account identity verification outside Miftah. A returning provider-owned account can now be added atomically through the reviewed adapter contract in either CLI or Console, with a fresh isolated state directory, a redacted fail-closed lifecycle record, and no token-cache access. It also adds a separate returning-user path for a simple local STDIO environment credential binding: CLI and Console accept a variable name rather than a secret, require every existing profile to share the same narrow binding, enforce multi-profile safeguards, use guarded audited replacement, and refuse remote HTTP, provider-adapter, OAuth, named-upstream, mixed-profile, duplicate-source, and arbitrary-override configurations. It also adds explicitly acknowledged local STDIO setup and client-entry import: literal executable plus bounded argument array, no shell, no secret-shaped values, no generic-command launch during onboarding, read-only default policy, destructive handling for unknown tools, and Windows-only direct absolute `.exe`/`.com` requirements to avoid a command-shell fallback. Bare interactive `miftah setup` now also lets a user choose one selected existing client entry: it reads one explicit absolute client file through a bounded verified handle, lists only safe entry names, imports from that same inspected snapshot, and never prints source command, argument, header, environment, or credential values. Returning CLI users can now review the durable default and fixed non-secret account metadata with `profile list`, and rerun only the selected provider adapter's declared, audited read-only readiness check with `profile test`, receiving a redacted report and nonzero outcome for any non-ready state without an arbitrary tool call, browser handoff, configuration mutation, or provider-cache access. It also adds a guarded CLI and local Console flow for setting or explicitly clearing one existing profile's non-secret description. It preserves all other profile fields, durable default, routing, OAuth bindings, credential and provider-cache ownership; writes through the existing audited replacement and exact-recovery transaction; and returns no configuration bytes or submitted label. ### Changed diff --git a/README.md b/README.md index 7954197..74d3e5f 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Shell examples below use POSIX syntax, including `~`, `$HOME`, and `\` line cont miftah setup ``` -This starts by asking what you have: enter `remote` for a remote HTTPS endpoint, `local` for a reviewed executable and argument array, or a connector name such as `google-search-console`. It then collects the safe metadata that path needs, an output location, and an optional client JSON snippet. It never asks for a token, password, or browser cookie. For a remote OAuth server, run `miftah setup --native-oauth`: it asks for the exact HTTPS endpoint and account name, then discovers whether Miftah can safely own the OAuth flow. The plain `remote` path does not discover OAuth or call the upstream, so use `--native-oauth` when the server advertises standards-based OAuth. For `local`, it collects one literal argument at a time, shows a no-secret review summary, and requires an explicit acknowledgement. Miftah does not run the local executable during setup. Miftah validates the complete configuration before it writes an owner-restricted file, never overwrites an existing one, and never edits a Claude, Cursor, VS Code, or other MCP client file. Recognized adapters can then offer one explicit, provider-declared read-only readiness check; Miftah never guesses a tool or auto-approves a policy prompt. Use `miftah init` when you want the same catalog in a scripted command. +On a bare interactive `miftah setup`, first choose `new` or `import` at `Start from (new, import) [new]`. Choose `new` and this starts by asking what you have: enter `remote` for a remote HTTPS endpoint, `local` for a reviewed executable and argument array, or a connector name such as `google-search-console`. It then collects the safe metadata that path needs and an output location, and can print an optional client JSON snippet for manual review. Choose `import` when you already have one MCP entry in a client JSON file: Miftah asks for that explicit absolute file, lists only entry names, and asks you to select one. It never prints the source entry's command, arguments, headers, environment values, or credentials, and it does not scan or modify the source client file. It never asks for a token, password, or browser cookie. For a remote OAuth server, run `miftah setup --native-oauth`: it asks for the exact HTTPS endpoint and account name, then discovers whether Miftah can safely own the OAuth flow. The plain `remote` path does not discover OAuth or call the upstream, so use `--native-oauth` when the server advertises standards-based OAuth. For `local`, it collects one literal argument at a time, shows a no-secret review summary, and requires an explicit acknowledgement. Miftah does not run the local executable during setup. Miftah validates the complete configuration before it writes an owner-restricted file, never overwrites an existing one, and never edits a Claude, Cursor, VS Code, or other MCP client file. Recognized adapters can then offer one explicit, provider-declared read-only readiness check; Miftah never guesses a tool or auto-approves a policy prompt. Use `miftah init` when you want the same catalog in a scripted command. ## First setup: GitHub with Claude Desktop @@ -221,7 +221,15 @@ For one reviewed local executable, use `local-stdio` for arguments and a working ## Reuse one existing MCP client entry -If you already have a local or remote Claude Desktop, Claude Code, Cursor, or VS Code MCP entry, `miftah setup` can create a safe first Miftah configuration from one entry you explicitly choose. It does not scan or modify the source client file. +If you already have a local or remote Claude Desktop, Claude Code, Cursor, or VS Code MCP entry, start with: + +```bash +miftah setup +``` + +At `Start from (new, import) [new]`, choose `import`. Give the exact absolute client configuration file, then choose one listed entry by number or exact name. This is the simplest first-use path: Miftah lists only entry names and never prints the source entry's command, arguments, headers, environment values, or credentials. It does not scan or modify the source client file. + +For scripts or a repeatable handoff, use the explicit equivalent: ```bash miftah setup posthog-work \ diff --git a/docs/cli.md b/docs/cli.md index 830d807..23cf790 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -24,7 +24,7 @@ Documented command names, options, JSON success forms, and exit categories are c | `miftah validate --config ` | `--config` | `--config ` | Validates the JSON configuration without starting an upstream. Writes a JSON object with `ok`, `name`, and `profiles`. | | `miftah doctor --config ` | `--config` | `--config `, `--json` | Validates configuration and checks upstream readiness. Default output is a human-readable report; `--json` writes only the JSON report. A healthy or degraded report exits `0`; a failed report exits `1`. | | `miftah schema` | none | none | Writes the Miftah JSON Schema as pretty-printed JSON. | -| `miftah setup [name]` | none, or `--add-profile --config ` | The safe preset options accepted by `init`, except `--interactive`, plus `--verify`; `--import-file ` with `--import-entry `; `--add-profile --config --profile --oauth-client-secrets-file ` for a reviewed provider-owned account; or `--add-profile --config --profile --credential-env ` for a simple local environment-backed account | Opens the guided first-configuration flow. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For `local-stdio`, it collects literal argv elements, shows a no-secret review summary, and requires acknowledgement before saving; it never starts that generic local executable. For Google Search Console it can collect one or more named accounts and an explicit default. `--add-profile` either adds one later reviewed provider account or, only for a single simple local credential binding, a new environment-backed account. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile, or just the newly added account; it is unavailable for generic environment-backed addition and client-entry import because Miftah does not infer a safe probe. The explicit import path accepts one selected local stdio or credential-free HTTPS remote entry and never edits a client configuration file. | +| `miftah setup [name]` | none, or `--add-profile --config ` | The safe preset options accepted by `init`, except `--interactive`, plus `--native-oauth` and `--verify`; `--import-file ` with `--import-entry `; `--add-profile --config --profile --oauth-client-secrets-file ` for a reviewed provider-owned account; or `--add-profile --config --profile --credential-env ` for a simple local environment-backed account | Opens the guided first-configuration flow. With a bare TTY invocation, it first offers `new` or `import`; import requires one explicit absolute client JSON file, lists only entry names, and requires one selected entry. It asks for no secret value, validates the complete candidate, creates the output exclusively with owner-restricted permissions, and can print client JSON for manual review. For `local-stdio`, it collects literal argv elements, shows a no-secret review summary, and requires acknowledgement before saving; it never starts that generic local executable. For Google Search Console it can collect one or more named accounts and an explicit default. `--add-profile` either adds one later reviewed provider account or, only for a single simple local credential binding, a new environment-backed account. `--verify` opts into one provider-declared read-only check for every created reviewed adapter profile, or just the newly added account; it is unavailable for generic environment-backed addition and client-entry import because Miftah does not infer a safe probe. The explicit and guided import paths accept one selected local stdio or credential-free HTTPS remote entry and never edit a client configuration file. | | `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix `, `--oauth-client-secrets-file `, `--local-command `, repeated `--arg `, `--cwd `, `--accept-local-command` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | | `miftah migrate-config --config ` | `--config` | `--config `, `--write` | Plans a supported configuration-format migration and writes a safe JSON report. It is dry-run by default. `--write` validates the candidate, makes an exact exclusive `.bak`, then uses a same-directory non-overwriting publication for a changed regular non-symlink source; it never resolves secrets or starts an upstream. | | `miftah profile list --config ` | `--config` | `--config ` | Lists the durable default and fixed non-secret metadata for every configured account: name, optional description/tags/policy, and profile-level upstream override names. It only loads and validates configuration; it never resolves secret references, reads credentials, headers, OAuth vault entries, or provider token caches, starts an upstream, or changes configuration. | @@ -72,13 +72,13 @@ miftah validate --config "$HOME/Miftah configs/work wrapper.json" `generic-npx` requires `--npm-package` with exact package SemVer; `generic-docker` requires a canonical digest in `--docker-image`; `streamable-http` requires `--url` plus optional credential environment/header metadata; and one-account noninteractive `google-search-console` requires `--oauth-client-secrets-file` with an absolute path. `local-stdio` requires `--local-command` and `--accept-local-command`; it accepts repeated literal `--arg` values, optional native absolute `--cwd`, and optional `--credential-env`. Use `--arg=--flag` for a value that begins with a dash. On Windows, the local executable must be an absolute `.exe` or `.com` binary; bare commands and `.cmd`/`.bat` shims are rejected so the direct argv path never uses a command processor. On Windows, `generic`, `sentry`, and `generic-npx` are unavailable because npm's `npx` runner requires a command shell. It rejects shell executables and wrappers, URL-like commands, environment references, controls, and credential-shaped command or argument values. It writes a read-only profile, treats unknown tools as destructive, and does not launch a generic local executable during `init` or `setup`. Guided GSC setup can collect one or more named accounts, their client-secrets paths, and an explicit default profile. `--credential-env` is optional where supported. The GSC adapter prints credential/browser/identity ownership without printing a configured path. See [preset and client compatibility](presets-and-clients.md) for exact inputs, pins, provenance, and client snippets, and [provider adapters](provider-adapters.md) for the upstream-owned OAuth boundary. -`--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C while it is collecting initial configuration data cancels without writing a config. In `miftah setup`, the first answer can be `remote` for the strict `streamable-http` path, `local` for `local-stdio`, or any catalog connector name. It asks for variable names and safe metadata, never secret values. The `remote` answer does not discover OAuth or call the upstream; use `miftah setup --native-oauth` only when the remote server advertises standards-based OAuth. For `local-stdio`, it asks for one argv element at a time and prints only a bounded count-based review summary before the acknowledgement; it does not echo arguments or credential values. For Google Search Console it asks for each account's profile name, optional description, and client-secrets path, then an explicit default profile. `miftah setup --verify` is an explicit opt-in to run each selected adapter's declared safe read-only check after the configuration write. It never accepts a tool name or arguments from the operator, never guesses a health command, does not auto-approve a policy confirmation, and returns only bounded status rather than provider output. The check runs only while the selected profile still matches the adapter's reviewed launch envelope; otherwise it reports a bounded unsupported status without launching the provider. If the post-write readiness prompt is cancelled, Miftah keeps the configuration, reports incomplete verification, and exits 1. A non-ready `setup --verify` result also keeps the configuration and exits 1. `init` is network-free and does not accept `--verify` or client-entry import flags. In noninteractive use, `init` creates only the config unless `--client` is supplied. `--client` prints JSON with absolute Node and compiled Miftah paths; it does not write a host config. For `claude-code` or `all`, it also prints a separate, exact management-tool `permissions.ask` fragment for manual merge into Claude Code settings; it never writes or overwrites those settings. Regenerate the snippets after moving or upgrading Miftah or changing the config path. +`--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C while it is collecting initial configuration data cancels without writing a config. A bare `miftah setup` begins with `Start from (new, import) [new]`: choose `new` to answer `remote` for the strict `streamable-http` path, `local` for `local-stdio`, or any catalog connector name; choose `import` to provide one explicit absolute client JSON file and select one listed entry. Guided import renders entry names only, never source commands, arguments, headers, environment values, or credentials. It asks for variable names and safe metadata, never secret values. The `remote` answer does not discover OAuth or call the upstream; use `miftah setup --native-oauth` only when the remote server advertises standards-based OAuth. For `local-stdio`, it asks for one argv element at a time and prints only a bounded count-based review summary before the acknowledgement; it does not echo arguments or credential values. For Google Search Console it asks for each account's profile name, optional description, and client-secrets path, then an explicit default profile. `miftah setup --verify` is an explicit opt-in to run each selected adapter's declared safe read-only check after the configuration write. It never accepts a tool name or arguments from the operator, never guesses a health command, does not auto-approve a policy confirmation, and returns only bounded status rather than provider output. The check runs only while the selected profile still matches the adapter's reviewed launch envelope; otherwise it reports a bounded unsupported status without launching the provider. If the post-write readiness prompt is cancelled, Miftah keeps the configuration, reports incomplete verification, and exits 1. A non-ready `setup --verify` result also keeps the configuration and exits 1. `init` is network-free and does not accept `--verify` or client-entry import flags. In noninteractive use, `init` creates only the config unless `--client` is supplied. `--client` prints JSON with absolute Node and compiled Miftah paths; it does not write a host config. For `claude-code` or `all`, it also prints a separate, exact management-tool `permissions.ask` fragment for manual merge into Claude Code settings; it never writes or overwrites those settings. Regenerate the snippets after moving or upgrading Miftah or changing the config path. `miftah setup --add-profile --config ` has two typed returning-user paths. For a reviewed provider-owned adapter, noninteractive use requires `--profile` and `--oauth-client-secrets-file`; `--description`, `--make-default`, and `--verify` are optional. Miftah proves that the entire selected configuration matches one adapter and that every existing account has a literal absolute credential-file reference plus a distinct canonical provider-state directory. It then atomically adds one new profile and isolated state directory through that adapter contract. Miftah never reads the credential file or the upstream token cache. Unsupported, modified, mixed, or shared-state configurations fail closed with `PROVIDER_ACCOUNT_ADDITION_UNSUPPORTED`; an invalid credential-file path fails with `PROVIDER_ACCOUNT_INPUT_INVALID`. `--verify` checks the new account only. For a static local account, noninteractive use requires `--profile` and `--credential-env`; the latter is an environment-variable name, never a secret value. This path accepts only a configuration with exactly one unnamed `stdio` upstream, one direct `${ENV_NAME}` credential binding in every profile, one shared destination/policy, no provider adapter, no native OAuth, and no named upstreams or other profile overrides. It atomically adds one profile that points at a different environment variable, enforces profile-switch confirmation and explicit destructive selection, and never starts the upstream or reads the credential. Remote HTTP credentials use headers rather than profile environments, so remote configurations fail closed with `ENVIRONMENT_PROFILE_ADDITION_UNSUPPORTED`. Generic `--verify` is deliberately rejected because Miftah has no declared safe probe. The durable default changes only when `--make-default` is present, so existing MCP clients still need a restart or new connection. -`miftah setup --import-file --import-entry ` is a separate no-secret flow, not a generic client migration. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. It accepts either a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar—literal executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags—or one credential-free HTTPS remote entry. A remote import uses `url` under `mcpServers` or `servers` and must explicitly declare `type: "http"` or `"streamable-http"`. It requires HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments, and does not discover OAuth or call the remote endpoint. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. It rejects `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, URL userinfo, opaque credential-shaped URL path segments, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references. +`miftah setup --import-file --import-entry ` is a separate no-secret flow, not a generic client migration. The same flow is available to a first-time interactive user: run bare `miftah setup`, choose `import` at `Start from (new, import) [new]`, provide the exact file, and select one listed entry by number or exact name. The source file must be an absolute regular non-symlink file. Miftah reads it through one bounded verified handle, requires an explicitly selected entry, and never changes the source. Guided import lists safe entry names only; it does not print source command, argument, header, environment, or credential data. It accepts either a local `stdio` entry under `mcpServers` (Claude Desktop, Claude Code, or Cursor) or `servers` (VS Code) that fits its finite static launch grammar—literal executable, optional absolute working directory, and either an exact-version package-runner launch with only that runner's fixed safe prefix flags and no arguments after the package, a script path plus non-sensitive flags, or a direct executable plus non-sensitive flags—or one credential-free HTTPS remote entry. A remote import uses `url` under `mcpServers` or `servers` and must explicitly declare `type: "http"` or `"streamable-http"`. It requires HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments, and does not discover OAuth or call the remote endpoint. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. It rejects `env`, headers, shell settings, unknown fields, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, URL userinfo, opaque credential-shaped URL path segments, unpinned package references, and credential-shaped arguments. It creates a read-only default profile with unknown tool risk set to destructive and does not launch the imported program. `--verify` is rejected before publication because no reviewed provider adapter is inferred. Use advanced manual setup when the existing entry does not fit this grammar; configure upstream credentials or OAuth separately through the upstream's documented path and Miftah secret references. ### `migrate-config` diff --git a/src/cli/setup-client-entry-import.ts b/src/cli/setup-client-entry-import.ts index a6bf9f8..a62df7a 100644 --- a/src/cli/setup-client-entry-import.ts +++ b/src/cli/setup-client-entry-import.ts @@ -99,6 +99,29 @@ function writeClientHandoff(options: ClientEntryImportSetupOptions, result: Init export async function runClientEntryImportSetup( options: ClientEntryImportSetupOptions, context: InitCommandContext +): Promise { + if (options.importFile === undefined || options.importEntry === undefined) { + usageError("Client-entry import requires both '--import-file ' and '--import-entry '."); + } + let document: string; + try { + document = await readClientEntryImportFile(options.importFile); + } catch (error) { + if (error instanceof ClientEntryImportError) usageError(error.message); + throw error; + } + return runClientEntryImportSetupFromDocument(options, context, document); +} + +/** + * Publishes from a document already read through the bounded verified source + * reader. Guided import uses this to keep its displayed entry list and its + * selected entry bound to the same source snapshot. + */ +export async function runClientEntryImportSetupFromDocument( + options: ClientEntryImportSetupOptions, + context: InitCommandContext, + document: string ): Promise { if (options.importFile === undefined || options.importEntry === undefined) { usageError("Client-entry import requires both '--import-file ' and '--import-entry '."); @@ -112,7 +135,6 @@ export async function runClientEntryImportSetup( const output = resolveOutputPath(options.output ?? `${name}.miftah.json`, context.cwd); let config: InitCommandResult["config"]; try { - const document = await readClientEntryImportFile(options.importFile); config = createImportedClientConfiguration({ configurationName: name, document, diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 4242adc..7421250 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,13 +1,21 @@ import { createInterface } from "node:readline/promises"; import { resolve } from "node:path"; import { runProfileReadiness, type ProfileReadinessReport } from "../setup/profile-readiness.js"; +import { + ClientEntryImportError, + inspectClientConfiguration +} from "../setup/client-entry-import.js"; +import { readClientEntryImportFile } from "../setup/client-entry-import-file.js"; import { loadConfig } from "../config/load-config.js"; import { getProviderAdapterForAccountProvisioning } from "../config/provider-adapters.js"; import { MiftahError } from "../utils/errors.js"; import { CliUsageError } from "./parse.js"; import type { CliOptions } from "./parse.js"; import { runInitCommand, type InitCommandContext, type InitCommandOptions } from "./init.js"; -import { runClientEntryImportSetup } from "./setup-client-entry-import.js"; +import { + runClientEntryImportSetup, + runClientEntryImportSetupFromDocument +} from "./setup-client-entry-import.js"; import { runNativeOAuthSetup } from "./setup-native-oauth.js"; import { runProviderAccountSetup } from "./setup-provider-account.js"; import { runEnvironmentProfileSetup } from "./setup-environment-profile.js"; @@ -27,6 +35,12 @@ export interface SetupCommandResult { type ReadinessDecision = "verify" | "skip" | "cancelled"; type AccountAdditionKind = "provider" | "environment"; +type GuidedSetupStartingPoint = "new" | "import"; + +interface InteractivePromptSession { + prompt(label: string, defaultValue?: string): Promise; + close(): void; +} function flagName(option: string): string { return option.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); @@ -36,6 +50,128 @@ function isTty(context: InitCommandContext): boolean { return context.input.isTTY === true && context.output.isTTY === true; } +function createInteractivePromptSession( + context: InitCommandContext, + cancellationMessage: string +): InteractivePromptSession { + const line = createInterface({ input: context.input, output: context.output, terminal: true }); + const cancelled = Symbol("cancelled"); + let resolveCancellation: (value: typeof cancelled) => void = () => undefined; + const cancellation = new Promise((resolve) => { + resolveCancellation = resolve; + }); + const cancel = () => resolveCancellation(cancelled); + line.once("close", cancel); + line.once("SIGINT", cancel); + + return { + async prompt(label: string, defaultValue?: string): Promise { + const suffix = defaultValue === undefined ? ": " : ` [${defaultValue}]: `; + const answer = await Promise.race([cancellation, line.question(`${label}${suffix}`)]); + if (answer === cancelled) throw new CliUsageError(cancellationMessage); + const value = answer.trim(); + return value === "" ? defaultValue : value; + }, + close(): void { + line.removeListener("close", cancel); + line.removeListener("SIGINT", cancel); + line.close(); + } + }; +} + +function hasExplicitNewConfigurationInput(options: SetupCommandOptions): boolean { + return [ + options.name, + options.preset, + options.output, + options.client, + options.credentialEnv, + options.npmPackage, + options.dockerImage, + options.url, + options.headerName, + options.headerPrefix, + options.oauthClientSecretsFile, + options.localCommand, + options.args, + options.cwd, + options.acceptLocalCommand, + options.googleSearchConsoleProfiles, + options.defaultProfile, + options.verify + ].some((value) => value !== undefined); +} + +async function chooseGuidedSetupStartingPoint(context: InitCommandContext): Promise { + const prompts = createInteractivePromptSession(context, "Guided setup was cancelled."); + try { + const answer = (await prompts.prompt("Start from (new, import)", "new"))?.toLowerCase(); + if (answer === "new" || answer === "n") return "new"; + if (answer === "import" || answer === "i") return "import"; + throw new CliUsageError("Choose 'new' to configure an MCP or 'import' to select an existing client entry."); + } finally { + prompts.close(); + } +} + +function selectedGuidedClientEntry(answer: string | undefined, entries: readonly string[]): string { + if (answer === undefined || answer.length === 0) { + throw new CliUsageError("Choose one listed MCP entry by number or exact name."); + } + if (entries.includes(answer)) return answer; + if (/^[1-9][0-9]*$/u.test(answer)) { + const index = Number(answer) - 1; + if (index >= 0 && index < entries.length) return entries[index]!; + } + throw new CliUsageError("Choose one listed MCP entry by number or exact name."); +} + +/** + * Keeps a selected existing client entry outcome-first without granting it any + * authority: source bytes stay private, only safe entry names are rendered, + * and the existing shared importer performs the sole conversion and write. + */ +async function runGuidedClientEntryImport(context: InitCommandContext): Promise { + const prompts = createInteractivePromptSession(context, "Guided client-entry import was cancelled."); + try { + const importFile = await prompts.prompt("Client configuration file (absolute path)"); + if (importFile === undefined) throw new CliUsageError("Choose an absolute client configuration file before importing."); + + let document: string; + let entries: readonly string[]; + try { + document = await readClientEntryImportFile(importFile); + entries = inspectClientConfiguration(document).entries; + } catch (error) { + if (error instanceof ClientEntryImportError) throw new CliUsageError(error.message); + throw error; + } + context.output.write(`Available MCP entries (names only):\n${entries.map((entry, index) => `${index + 1}. ${entry}\n`).join("")}`); + const importEntry = selectedGuidedClientEntry( + await prompts.prompt("MCP entry to import (number or exact name)"), + entries + ); + const name = await prompts.prompt("Configuration name", "miftah-import"); + if (name === undefined) throw new CliUsageError("Choose a configuration name before importing."); + const output = await prompts.prompt("Output location", `${name}.miftah.json`); + if (output === undefined) throw new CliUsageError("Choose an output location before importing."); + const client = await prompts.prompt( + "Client (claude-desktop, claude-code, cursor, vscode, all; blank for config only)" + ); + + await runClientEntryImportSetupFromDocument({ + name, + output, + ...(client === undefined ? {} : { client }), + importFile, + importEntry + }, context, document); + } finally { + prompts.close(); + } +} + async function accountAdditionKind(options: SetupCommandOptions, context: InitCommandContext): Promise { if (options.config === undefined) { throw new CliUsageError("Adding an account profile requires --config."); @@ -165,6 +301,13 @@ export async function runSetupCommand(options: SetupCommandOptions, context: Ini // inherit a reviewed provider adapter and are never launched during import. return { verification: "not-applicable", exitCode: 0, reports: [] }; } + if (isTty(context) && !hasExplicitNewConfigurationInput(options)) { + const startingPoint = await chooseGuidedSetupStartingPoint(context); + if (startingPoint === "import") { + await runGuidedClientEntryImport(context); + return { verification: "not-applicable", exitCode: 0, reports: [] }; + } + } const created = await runInitCommand({ ...options, interactive: true }, context); if ( created.config.version === "3" && diff --git a/tests/preset-docs-contract.test.ts b/tests/preset-docs-contract.test.ts index 349d801..00d04ce 100644 --- a/tests/preset-docs-contract.test.ts +++ b/tests/preset-docs-contract.test.ts @@ -76,6 +76,7 @@ describe("preset documentation contract", () => { expect(readme).toContain("[Preset and client compatibility](docs/presets-and-clients.md)"); expect(cli).toContain("[preset and client compatibility](presets-and-clients.md)"); + expect(cli).toContain("except `--interactive`, plus `--native-oauth` and `--verify`"); expect(claudeDesktop).toContain("Merge the generated top-level `mcpServers` property"); expect(claudeDesktop).toMatch(/merge the generated server entry into that object instead of nesting/iu); expect(claudeDesktop).not.toContain("Paste the generated JSON into the `mcpServers` object"); diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index b788cd2..d4c362e 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -57,6 +57,7 @@ describe("product README", () => { expect(readme).toContain("miftah setup"); expect(readme).toContain("enter `remote` for a remote HTTPS endpoint"); expect(readme).toContain("`local` for a reviewed executable and argument array"); + expect(readme).toContain("can print an optional client JSON snippet for manual review"); expect(readme).toContain("The plain `remote` path does not discover OAuth or call the upstream"); expect(readme).toContain("use `--native-oauth` when the server advertises standards-based OAuth"); expect(readme).toContain("never asks for a token, password, or browser cookie"); @@ -145,6 +146,9 @@ describe("product README", () => { it("documents the explicit no-secret local and remote client-entry import paths", () => { expect(readme).toContain("## Reuse one existing MCP client entry"); + expect(readme).toContain("Start from (new, import) [new]"); + expect(readme).toContain("lists only entry names"); + expect(readme).toContain("never prints the source entry's command, arguments, headers, environment values, or credentials"); expect(readme).toContain("--import-file"); expect(readme).toContain("--import-entry"); expect(readme).toContain("does not scan or modify the source client file"); diff --git a/tests/setup-command.test.ts b/tests/setup-command.test.ts index 6aa56fa..dd2849d 100644 --- a/tests/setup-command.test.ts +++ b/tests/setup-command.test.ts @@ -417,6 +417,7 @@ describe("setup command", () => { } }); + await answer(streams, "Start from (new, import) [new]", "new"); await answer(streams, "Name [miftah-wrapper]", "guided"); await answer(streams, "What do you want to set up? (connector name, remote, or local)", "generic-docker"); await answer( @@ -519,6 +520,273 @@ describe("setup command", () => { expect(await readFile(source, "utf8")).toBe(document); }); + it("guides a user through selecting one existing client entry without rendering source credentials", async () => { + const source = resolve(outputRoot, "claude-desktop.json"); + const output = resolve(outputRoot, "analytics.json"); + const entry = importableClientEntry(); + const secret = "client-source-secret-must-not-be-rendered"; + const document = JSON.stringify({ + mcpServers: { + analytics: entry, + private: { + command: entry.command, + args: entry.args, + env: { PRIVATE_TOKEN: secret } + } + } + }); + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(source, document, { mode: 0o600 }); + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer(streams, "Start from (new, import) [new]", "import"); + await answer(streams, "Client configuration file (absolute path)", source); + await answer(streams, "MCP entry to import (number or exact name)", "1"); + await answer(streams, "Configuration name [miftah-import]", "analytics"); + await answer(streams, "Output location [analytics.miftah.json]", "analytics.json"); + await answer(streams, "Client (claude-desktop, claude-code, cursor, vscode, all; blank for config only)", ""); + + await expect(command).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + streams.input.end(); + + expect(validateConfig(JSON.parse(await readFile(output, "utf8")))).toMatchObject({ + name: "analytics", + upstream: { transport: "stdio", command: entry.command, args: entry.args }, + profiles: { default: { policy: "readonly" } } + }); + expect(await readFile(source, "utf8")).toBe(document); + expect(streams.transcript.contents).toContain("Available MCP entries (names only):"); + expect(streams.transcript.contents).toContain("1. analytics"); + expect(streams.transcript.contents).toContain("2. private"); + expect(streams.transcript.contents).not.toContain(secret); + }); + + it("honors an exact numeric client entry name before interpreting a list position", async () => { + const source = resolve(outputRoot, "numeric-entry.json"); + const output = resolve(outputRoot, "numeric-entry.miftah.json"); + const namedTwoUrl = "https://mcp.example.test/named-two"; + const secondEntryUrl = "https://mcp.example.test/second-entry"; + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(source, JSON.stringify({ + mcpServers: { + "2": { type: "http", url: namedTwoUrl }, + analytics: { type: "http", url: secondEntryUrl } + } + }), { mode: 0o600 }); + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer(streams, "Start from (new, import) [new]", "import"); + await answer(streams, "Client configuration file (absolute path)", source); + await answer(streams, "MCP entry to import (number or exact name)", "2"); + await answer(streams, "Configuration name [miftah-import]", "numeric-entry"); + await answer(streams, "Output location [numeric-entry.miftah.json]", "numeric-entry.miftah.json"); + await answer(streams, "Client (claude-desktop, claude-code, cursor, vscode, all; blank for config only)", ""); + + await expect(command).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + streams.input.end(); + expect(validateConfig(JSON.parse(await readFile(output, "utf8")))).toMatchObject({ + upstream: { transport: "streamable-http", url: namedTwoUrl } + }); + }); + + it("imports the inspected client file snapshot when its source path changes before entry confirmation", async () => { + const source = resolve(outputRoot, "changing-client.json"); + const output = resolve(outputRoot, "snapshot.miftah.json"); + const inspectedUrl = "https://mcp.example.test/inspected"; + const changedUrl = "https://mcp.example.test/changed"; + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(source, JSON.stringify({ + mcpServers: { analytics: { type: "http", url: inspectedUrl } } + }), { mode: 0o600 }); + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer(streams, "Start from (new, import) [new]", "import"); + await answer(streams, "Client configuration file (absolute path)", source); + await streams.transcript.waitFor("MCP entry to import (number or exact name)"); + await writeFile(source, JSON.stringify({ + mcpServers: { analytics: { type: "http", url: changedUrl } } + }), { mode: 0o600 }); + await answer(streams, "MCP entry to import (number or exact name)", "analytics"); + await answer(streams, "Configuration name [miftah-import]", "snapshot"); + await answer(streams, "Output location [snapshot.miftah.json]", "snapshot.miftah.json"); + await answer(streams, "Client (claude-desktop, claude-code, cursor, vscode, all; blank for config only)", ""); + + await expect(command).resolves.toEqual({ verification: "not-applicable", exitCode: 0, reports: [] }); + streams.input.end(); + expect(validateConfig(JSON.parse(await readFile(output, "utf8")))).toMatchObject({ + upstream: { transport: "streamable-http", url: inspectedUrl } + }); + }); + + it("rejects an unlisted guided client-entry selection before any configuration write", async () => { + const source = resolve(outputRoot, "selection-client.json"); + const original = JSON.stringify({ mcpServers: { analytics: importableClientEntry() } }); + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(source, original, { mode: 0o600 }); + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer(streams, "Start from (new, import) [new]", "import"); + await answer(streams, "Client configuration file (absolute path)", source); + await answer(streams, "MCP entry to import (number or exact name)", "missing"); + + await expect(command).rejects.toThrow("Choose one listed MCP entry by number or exact name."); + streams.input.end(); + expect(await readFile(source, "utf8")).toBe(original); + }); + + it("cancels guided client-entry import on EOF or SIGINT before selecting or writing a configuration", async () => { + const source = resolve(outputRoot, "cancelled-client.json"); + const original = JSON.stringify({ mcpServers: { analytics: importableClientEntry() } }); + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(source, original, { mode: 0o600 }); + + for (const cancel of [ + (streams: ReturnType) => streams.input.end(), + (streams: ReturnType) => streams.input.write("\u0003") + ]) { + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer(streams, "Start from (new, import) [new]", "import"); + await streams.transcript.waitFor("Client configuration file (absolute path)"); + cancel(streams); + + await expect(command).rejects.toThrow("Guided client-entry import was cancelled."); + streams.input.end(); + expect(await readFile(source, "utf8")).toBe(original); + } + }); + + it("reports guided client-entry cancellation safely between prompts", async () => { + const source = resolve(outputRoot, "cancel-between-prompts.json"); + const original = JSON.stringify({ mcpServers: { analytics: importableClientEntry() } }); + await mkdir(outputRoot, { recursive: true, mode: 0o700 }); + await writeFile(source, original, { mode: 0o600 }); + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer(streams, "Start from (new, import) [new]", "import"); + await answer(streams, "Client configuration file (absolute path)", source); + streams.input.end(); + + await expect(command).rejects.toThrow("Guided client-entry import was cancelled."); + expect(await readFile(source, "utf8")).toBe(original); + await expect(readFile(resolve(outputRoot, "miftah-import.miftah.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("cancels the initial guided setup choice on EOF or SIGINT before any configuration write", async () => { + for (const cancel of [ + (streams: ReturnType) => streams.input.end(), + (streams: ReturnType) => streams.input.write("\u0003") + ]) { + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await streams.transcript.waitFor("Start from (new, import) [new]"); + cancel(streams); + + await expect(command).rejects.toThrow("Guided setup was cancelled."); + streams.input.end(); + await expect(readFile(resolve(outputRoot, "miftah-wrapper.miftah.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + } + }); + + it("rejects an unsupported guided setup starting point before any configuration write", async () => { + const streams = createStreams(); + const command = runSetupCommand({}, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await answer(streams, "Start from (new, import) [new]", "unsupported"); + await expect(command).rejects.toThrow("Choose 'new' to configure an MCP or 'import' to select an existing client entry."); + streams.input.end(); + await expect(readFile(resolve(outputRoot, "miftah-wrapper.miftah.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("preserves the established setup wizard entry point when --verify is explicit", async () => { + const streams = createStreams(); + const command = runSetupCommand({ verify: true }, { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }); + + await streams.transcript.waitFor("Name [miftah-wrapper]"); + expect(streams.transcript.contents).not.toContain("Start from (new, import)"); + streams.input.end(); + await expect(command).rejects.toThrow("Interactive init was cancelled"); + }); + it("imports one explicitly selected remote HTTPS entry without OAuth discovery or an upstream call", async () => { const source = resolve(outputRoot, "cursor-remote.json"); const output = resolve(outputRoot, "remote-analytics.json"); @@ -1131,6 +1399,7 @@ describe("setup command", () => { } }); + await answer(streams, "Start from (new, import) [new]", "new"); await answer(streams, "Name [miftah-wrapper]", "gsc"); await answer(streams, "What do you want to set up? (connector name, remote, or local)", "google-search-console"); await answer(streams, "Google account profile name [google-account-1]", "google-govalidate");