diff --git a/docs/code.md b/docs/code.md index 1a903f5..f527049 100644 --- a/docs/code.md +++ b/docs/code.md @@ -20,7 +20,7 @@ the integration (`code:execute` for `invoke`). | `norbix code qualifiers --integration --function ` | the aliases and versions: name, kind (Alias / Version), the version an alias points at | `GET /qualifiers` | | `norbix code invoke --integration --function [--qualifier ] [--payload '' \| --payload-file ] [--async] [--run-as ] [--config ''] [--dry-run]` | Sync (default): the status code, the duration in ms, the function's output and the provider's request id. `--async`: that the call was accepted | `POST /invoke` | | `norbix code event-sample --source [--event ] [--schema ]` | the full envelope with sample data — the fixture for `sam local invoke`, `func start` or the Functions Framework | `GET /event-sample` | -| `norbix code runs --trigger \| --task [--limit <1-50>]` | the last runs, newest first: fired at, event, succeeded, status code, duration, error, request id — a run through the Fake provider also its `input`, the envelope the function received (`context.runAs.accessToken` masked, cut at 64 KB), pretty-printed under the row | `GET /runs` | +| `norbix code runs --trigger \| --task [--limit <1-50>]` | the last runs, newest first: fired at, event, succeeded, status code, duration, error, request id — a run through the Sandbox provider (API value `Fake`) also its `input`, the envelope the function received (`context.runAs.accessToken` masked, cut at 64 KB), pretty-printed under the row | `GET /runs` | `--json` on any of them prints the raw answer. `invoke` sends the function a `manual.invoke` envelope with the payload in `data.payload`; `context.runAs` is @@ -79,7 +79,7 @@ All paths below are under `/{version}/code/marketplace`. | `norbix code marketplace functions add --connection --key [--name ] [--arguments ''] [--arg k=v]… [--input name[:type[:required]]]…` | adds a function and prints the minted id | `POST /integrations/{id}/functions` | | `norbix code marketplace functions enable\|disable\|delete --connection ` | turns it on / off, deletes it | `POST …/enable`, `POST …/disable`, `DELETE …` | | `norbix code marketplace functions invoke [--payload '' \| --payload-file ]` | calls it as you; prints `isSuccess`, `output`, `vendorRequestId` | `POST /functions/{func}/invoke` | -| `norbix code marketplace runs --function \| --trigger \| --task \| --connection [--limit <1-50>] [--details]` | the last runs, newest first: `--function` every run of one function from every source (invoke, trigger, scheduler; 50 kept), `--trigger` / `--task` the runs it started (20 kept), `--connection` the tool calls an AI chat made on an MCP connection without a saved function (rows carry `integrationId` and `toolName`). A row has `status` (`ok` / `failed` / `skipped`), `durationMs`, `source`, `correlationId`, `hasDetails`; a Fake-vendor run also its `input` (secrets `***`), pretty-printed under the row. `--details` adds each run's log lines (input, steps, answer or error) for rows with `hasDetails: true`; any other row says "details need the project's logs on" | `GET /functions/runs`; `--details`: `GET /logs/audit?targetCorrelationId=…` per row | +| `norbix code marketplace runs --function \| --trigger \| --task \| --connection [--limit <1-50>] [--details]` | the last runs, newest first: `--function` every run of one function from every source (invoke, trigger, scheduler; 50 kept), `--trigger` / `--task` the runs it started (20 kept), `--connection` the tool calls an AI chat made on an MCP connection without a saved function (rows carry `integrationId` and `toolName`). A row has `status` (`ok` / `failed` / `skipped`), `durationMs`, `source`, `correlationId`, `hasDetails`; a Sandbox-vendor run (API value `Fake`) also its `input` (secrets `***`), pretty-printed under the row. `--details` adds each run's log lines (input, steps, answer or error) for rows with `hasDetails: true`; any other row says "details need the project's logs on" | `GET /functions/runs`; `--details`: `GET /logs/audit?targetCorrelationId=…` per row | Notes: diff --git a/docs/email.md b/docs/email.md index 6550cc3..7622894 100644 --- a/docs/email.md +++ b/docs/email.md @@ -7,7 +7,7 @@ Every command is covered by `tests/email-routes.test.ts`, which runs it through the real SDK with `fetch` replaced and checks the verb and path it sends (so a wrong route token fails a test). `tests/email.test.ts` and `tests/email-existing.test.ts` also check the request fields. No test contacts -a mail service; the only provider a test saves is **Fake**. +a mail service; the only provider a test saves is **Sandbox** (API value `Fake`). All paths below are under `/{version}/notifications/email`, except `campaign messages` (the gateway spells that route `/notifications/emails/…`). @@ -39,16 +39,15 @@ first two, for scripts that treat every module alike. | `norbix email integration confirm-delivery ` | confirm the test e-mail reached a person | `POST /integrations/confirm-human-delivery` | | `norbix email integration domain-health ` | check SPF / DKIM / DMARC of the sending domain (read only) | `POST /integrations/domain-health` | -`--provider` is one of `Fake`, `Smtp`, `SendGrid`, `MailGun`, `AwsSes`. **Fake** -needs nothing else: it accepts every send and contacts no mail service, so +`--provider` is one of `Sandbox`, `Smtp`, `SendGrid`, `MailGun`, `AwsSes`. **Sandbox** (sent to the API as `Fake`) needs nothing else: it accepts every send and contacts no mail service, so nothing reaches a real inbox — use it while you develop. A project can hold one -Fake and it has no update path: delete it and add it again. The real providers +Sandbox and it has no update path: delete it and add it again. The real providers need a sender (`--from`) and their credentials: put the provider-specific fields in `--config` as a JSON object (inline, `@file.json`, or `-` for stdin) so a key does not land in your shell history. ```bash -norbix email integration save --provider Fake +norbix email integration save --provider Sandbox norbix email integration save --provider SendGrid --name SendGrid --from hello@example.com --config @sendgrid.json norbix email integration test --integration int_123 --to dev@example.com norbix email integration confirm-delivery int_123 diff --git a/docs/push.md b/docs/push.md index 6238f71..4ff1933 100644 --- a/docs/push.md +++ b/docs/push.md @@ -6,7 +6,7 @@ Every command is covered by `tests/push-routes.test.ts`, which runs it through the real SDK with `fetch` replaced and checks the verb and path it sends (so a wrong route token fails a test). `tests/push.test.ts` and `tests/push-existing.test.ts` also check the request fields. No test contacts a -push provider; the only provider a test saves is **Fake**. +push provider; the only provider a test saves is **Sandbox** (API value `Fake`). All paths below are under `/{version}/notifications/push`. @@ -33,15 +33,15 @@ All paths below are under `/{version}/notifications/push`. | `norbix push integration test --integration [--token ] [--family ]` | send a test push | `POST /integrations/test` | | `norbix push integration confirm-delivery ` | confirm the test push reached a person | `POST /integrations/confirm-human-delivery` | -`--provider` is one of `Fake`, `AppleApns`, `AndroidFirebase`, `SafariPush`, -`ChromeWeb`, `FirefoxWeb`, `EdgeWeb`, `ChromePush`. **Fake** needs nothing else: +`--provider` is one of `Sandbox`, `AppleApns`, `AndroidFirebase`, `SafariPush`, +`ChromeWeb`, `FirefoxWeb`, `EdgeWeb`, `ChromePush`. **Sandbox** (sent to the API as `Fake`) needs nothing else: it accepts every send and contacts no push service, so nothing reaches a real device — use it while you develop. The real providers need their credentials: put the provider-specific fields in `--config` as a JSON object (inline, `@file.json`, or `-` for stdin). ```bash -norbix push integration save --provider Fake +norbix push integration save --provider Sandbox norbix push integration save --provider AndroidFirebase --name Android --config @firebase.json norbix push integration test --integration int_123 --token dGVzdA== --family Ios norbix push integration confirm-delivery int_123 diff --git a/docs/sms.md b/docs/sms.md index 436378e..c2f9ab2 100644 --- a/docs/sms.md +++ b/docs/sms.md @@ -6,7 +6,7 @@ Every command is covered by `tests/sms-routes.test.ts`, which runs it through the real SDK with `fetch` replaced and checks the verb and path it sends (so a wrong route token fails a test). `tests/sms.test.ts` and `tests/sms-existing.test.ts` also check the request fields. No test contacts an -SMS provider; the only provider a test saves is **Fake**. +SMS provider; the only provider a test saves is **Sandbox** (API value `Fake`). All paths below are under `/{version}/notifications/sms`. @@ -36,16 +36,16 @@ first two, for scripts that treat every module alike. | `norbix sms integration test --integration [--to ]` | send a test SMS | `POST /integrations/test` | | `norbix sms integration confirm-delivery ` | confirm the test SMS reached a person | `POST /integrations/confirm-human-delivery` | -`--provider` is one of `Fake`, `Twilio`, `Vonage`, `Plivo`, `Telnyx`, `Bird`, -`Telesign`, `Sinch`. **Fake** needs nothing else: it accepts every send and +`--provider` is one of `Sandbox`, `Twilio`, `Vonage`, `Plivo`, `Telnyx`, `Bird`, +`Telesign`, `Sinch`. **Sandbox** (sent to the API as `Fake`) needs nothing else: it accepts every send and contacts no SMS service, so nothing reaches a real phone — use it while you -develop. A project can hold one Fake and it has no update path: delete it and +develop. A project can hold one Sandbox and it has no update path: delete it and add it again. The real providers need their credentials: put the provider-specific fields in `--config` as a JSON object (inline, `@file.json`, or `-` for stdin). ```bash -norbix sms integration save --provider Fake +norbix sms integration save --provider Sandbox norbix sms integration save --provider Twilio --name Twilio --config @twilio.json norbix sms integration test --integration int_123 --to +37060000000 norbix sms integration confirm-delivery int_123 diff --git a/docs/tasks/host-flag-and-sandbox.md b/docs/tasks/host-flag-and-sandbox.md new file mode 100644 index 0000000..453f942 --- /dev/null +++ b/docs/tasks/host-flag-and-sandbox.md @@ -0,0 +1,74 @@ +# CLI: --host on hub/api, Fake → Sandbox +This file: /Users/djovaisas/Projects/norbix/worktrees/sdks/cli/fix/host-flag/docs/tasks/host-flag-and-sandbox.md (branch fix/host-flag) + +## Goal +`norbix hub` / `norbix api` honour `--host` (and every other global flag), and the CLI calls the built-in test provider "Sandbox" everywhere a user reads it. +Not in scope: the gateway's enum / API value `Fake`, generated request-field data (src/generated), MCP tool names. Tracking file of the whole batch: /Users/djovaisas/Projects/norbix/worktrees/gateway/fix/small-batch/docs/tasks/small-fixes-batch.md (branch fix/small-batch) — owned by the tracking session. + +## Plan +1. [done] fix(cli:hub:host): `hub` and `api` keep --host, --language and --prod as global flags; discovery runs for the --host target — owner item 6 +2. [done] feat(cli:integrations:labels): `--provider Sandbox` replaces `--provider Fake` for email / SMS / push; the request still sends `Fake` — owner item 7 +3. [doing] release(cli): ship through nbx-ship (rebase merge) and cut a release + +## Changes +| file (absolute, branch fix/host-flag) | what changed | step | +|------|--------------|------| +| /Users/djovaisas/Projects/norbix/worktrees/sdks/cli/fix/host-flag/src/lib/namespace-command.ts | `knownFlags()` derives the global / command flag names; `discoveryArgv()` returns the split global part | 1 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/cli/fix/host-flag/src/base.ts | `init()` parses `discoveryArgv()` and gives back the argv oclif overwrote | 1 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/cli/fix/host-flag/tests/namespace-host.test.ts | new: two fake gateways ([default] profile host vs --host); 6 tests | 1 | +| /Users/djovaisas/Projects/norbix/worktrees/sdks/cli/fix/host-flag/src/lib/providers.ts | new: `providerFlag()` — options Sandbox + real providers, Sandbox → `Fake` on the wire | 2 | +| src/commands/{email,sms,push}/integration/{save,test}.ts | help, examples and flag texts say Sandbox | 2 | +| src/commands/code/runs.ts, src/commands/code/marketplace/runs.ts, src/lib/code.ts, src/lib/marketplace.ts | "Fake provider/vendor" → "Sandbox provider/vendor (API value Fake)" | 2 | +| docs/{email,sms,push,code}.md | same rename | 2 | +| tests/{email,sms,push}-routes.test.ts | Sandbox sends `Fake`; `--provider Fake` is refused with a list that names Sandbox | 2 | + +## Findings + +fix(cli:hub:host): `--host` (also `--language`, `--prod`) was not in the hand-kept global flag list, so it became a request field and the [default] profile host was used — done + where: /Users/djovaisas/Projects/norbix/worktrees/sdks/cli/fix/host-flag/src/lib/namespace-command.ts:59 (branch fix/host-flag) +```ts +// before — src/lib/namespace-command.ts:39-41 (origin/main 7dc7298) + protected splitArgv(): {flagArgv: string[]; rest: string[]; help: boolean} { + const VALUE_FLAGS = new Set(['--project', '--env', '--region', '--api-key', '--account', '--profile', '--body']) // <-- missing: --host, --language + const BOOL_FLAGS = new Set(['--json', '--yes', '-y', '--dry-run']) // <-- missing: --prod +``` +```ts +// after — src/lib/namespace-command.ts:59-61 (fix/host-flag) + protected splitArgv(): {flagArgv: string[]; rest: string[]; help: boolean} { + const ctor = this.constructor as typeof NamespaceCommand + const {VALUE_FLAGS, BOOL_FLAGS} = knownFlags({...BaseCommand.baseFlags, ...ctor.flags}) +``` + +fix(cli:base:discovery): `init()` parsed the full argv of `hub`/`api`, failed on the request fields, swallowed the error and skipped host discovery; and oclif's `parse` overwrites `this.argv` — done + where: /Users/djovaisas/Projects/norbix/worktrees/sdks/cli/fix/host-flag/src/base.ts:370 (branch fix/host-flag) +```ts +// after — src/base.ts:374-384 (fix/host-flag) + let flags: GlobalFlags + // oclif's parse replaces this.argv with what it parsed; `run` needs the whole argv back. + const argv = this.argv + try { + flags = (await this.parse(ctor as unknown as Parameters[0], this.discoveryArgv())).flags as GlobalFlags + } catch { + return + } finally { + this.argv = argv // <-- without this, `hub scheduler tasks get` lost its words and listed the modules + } +``` +```ts +// the test that proves it — tests/namespace-host.test.ts:45-51 (fix/host-flag); fails on origin/main, passes here + it('hub --host X goes to X, not to the [default] profile host', async () => { + const r = await cli(home, ['hub', 'scheduler', 'tasks', 'get', ...target(), '--json']) + expect(r.code, r.stderr).toBe(0) + expect(byDefault.hits).toHaveLength(0) + expect(byFlag.hits).toHaveLength(1) +``` + +## Rejected / moved out +- decision(cli:integrations): keep `Fake` as a hidden alias of `--provider` — rejected — reason: oclif checks `options` before `parse`, so an alias means dropping `options` and `norbix schema` would lose the provider list agents read; Norbix has no tenants yet (greenfield), so no back-compat. `--provider Fake` now fails with "Expected --provider=Fake to be one of: Sandbox, …". +- chore(cli): main checkout /Users/djovaisas/Projects/norbix/sdks/cli is on fix/sdk-1.3-and-deps with 1 changed file — not mine, untouched. + +## Needs you +- none + +## Open questions +- none diff --git a/src/base.ts b/src/base.ts index c0fd2f9..eb13f71 100644 --- a/src/base.ts +++ b/src/base.ts @@ -372,15 +372,24 @@ export abstract class BaseCommand extends Command { const ctor = this.constructor as typeof BaseCommand if (!ctor.discoversHost) return let flags: GlobalFlags + // oclif's parse replaces this.argv with what it parsed; `run` needs the whole argv back. + const argv = this.argv try { - flags = (await this.parse(ctor as unknown as Parameters[0])).flags as GlobalFlags + flags = (await this.parse(ctor as unknown as Parameters[0], this.discoveryArgv())).flags as GlobalFlags } catch { return + } finally { + this.argv = argv } await this.prepareHost(flags) } + /** The argv `init` parses to find the host; commands that split their own argv (hub, api) pass the global part only. */ + protected discoveryArgv(): string[] { + return this.argv + } + /** Discover the host `flags` point at, so `resolveContext` knows its Hub. */ protected async prepareHost(flags: GlobalFlags, opts: {refresh?: boolean} = {}): Promise { const target = this.pickTarget(flags) diff --git a/src/commands/code/marketplace/runs.ts b/src/commands/code/marketplace/runs.ts index d0d71a0..1455090 100644 --- a/src/commands/code/marketplace/runs.ts +++ b/src/commands/code/marketplace/runs.ts @@ -15,7 +15,7 @@ const ONE_OF = ['function', 'trigger', 'task', 'connection'] export default class CodeMarketplaceRuns extends BaseCommand { static description = `The last runs of a marketplace function, of a trigger, of a scheduler task or of an MCP connection's AI tool calls -Newest first: when it ran, the event (invoke for a call by a user, an API key or the AI), the source (user, api-key, ai, database, membership, files, payments, scheduler), the function, the run-as user, the status (ok, failed, skipped), the duration in ms, the error, the vendor's request id and the correlation id. --function lists every run of one function from every source (up to 50 kept); --trigger and --task list the runs that trigger or task started (20 kept). --connection lists the tool calls an AI chat made on an MCP connection without a saved function (each row names the toolName). A run through the Fake vendor also carries input — what the vendor received, secrets masked — printed as JSON under its row (a string with --json). +Newest first: when it ran, the event (invoke for a call by a user, an API key or the AI), the source (user, api-key, ai, database, membership, files, payments, scheduler), the function, the run-as user, the status (ok, failed, skipped), the duration in ms, the error, the vendor's request id and the correlation id. --function lists every run of one function from every source (up to 50 kept); --trigger and --task list the runs that trigger or task started (20 kept). --connection lists the tool calls an AI chat made on an MCP connection without a saved function (each row names the toolName). A run through the Sandbox vendor (API value Fake) also carries input — what the vendor received, secrets masked — printed as JSON under its row (a string with --json). --details also reads each run's log lines — input, steps, the answer or the error — by its correlation id (GET /logs/audit). They exist only for a run made while the project's logs were on in that environment (hasDetails: true); any other row says "${RUN_DETAILS_NEED_LOGS}". With --json each row gains "details" (the lines, or null) and, when null, "detailsNote".` diff --git a/src/commands/code/runs.ts b/src/commands/code/runs.ts index 351e111..b55ae30 100644 --- a/src/commands/code/runs.ts +++ b/src/commands/code/runs.ts @@ -6,7 +6,7 @@ import {formatRuns} from '../../lib/code.js' export default class CodeRuns extends BaseCommand { static description = `The last cloud-function runs of a trigger or a scheduler task -Newest first: when it fired, the event, whether it succeeded, the status code, the duration in ms, the error and the provider's request id. A run through the Fake provider also carries input — the envelope the function received, context.runAs.accessToken masked, cut at 64 KB — printed as JSON under its row (a string with --json). The same list as the Last runs panel of the trigger's or task's edit screen.` +Newest first: when it fired, the event, whether it succeeded, the status code, the duration in ms, the error and the provider's request id. A run through the Sandbox provider (API value Fake) also carries input — the envelope the function received, context.runAs.accessToken masked, cut at 64 KB — printed as JSON under its row (a string with --json). The same list as the Last runs panel of the trigger's or task's edit screen.` static examples = [ '<%= config.bin %> code runs --trigger trg_1p4R3gTobGZpD6fXvHT4rd', diff --git a/src/commands/email/integration/save.ts b/src/commands/email/integration/save.ts index c804180..e1997c0 100644 --- a/src/commands/email/integration/save.ts +++ b/src/commands/email/integration/save.ts @@ -1,17 +1,18 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' +import {providerFlag} from '../../../lib/providers.js' import {readJsonObject} from '../../../lib/email.js' -/** The provider names the server's integration converter accepts. */ -const PROVIDERS = ['Fake', 'Smtp', 'SendGrid', 'MailGun', 'AwsSes'] +/** The real provider names the server's integration converter accepts (Sandbox is added by providerFlag). */ +const PROVIDERS = ['Smtp', 'SendGrid', 'MailGun', 'AwsSes'] export default class EmailIntegrationSave extends BaseCommand { static description = `Create or update an email integration ---provider picks the body shape. Fake needs nothing else: it accepts every send +--provider picks the body shape. Sandbox needs nothing else: it accepts every send and contacts no mail service, so nothing reaches a real inbox — use it while you -develop (one Fake per project, no update — delete and add it again). The real +develop (one Sandbox per project, no update — delete and add it again). The real providers need a sender (--from) and their credentials: put the provider-specific fields in --config (inline JSON, @file.json, or - for stdin). Pass --id to update an existing integration. The output of \`email integration @@ -19,18 +20,18 @@ get\` can be fed back as --config (bare or wrapped in {"integration": …}): its viewId picks the integration to update, so no second one is created.` static examples = [ - '<%= config.bin %> email integration save --provider Fake', + '<%= config.bin %> email integration save --provider Sandbox', '<%= config.bin %> email integration save --provider SendGrid --name SendGrid --from hello@example.com --config @sendgrid.json', - '<%= config.bin %> email integration save --provider Fake --dry-run', + '<%= config.bin %> email integration save --provider Sandbox --dry-run', '<%= config.bin %> email integration save --provider SendGrid --config @integration-from-get.json', ] static flags = { ...BaseCommand.dryRunFlags, - provider: Flags.string({required: true, description: 'Email provider', options: PROVIDERS}), - name: Flags.string({description: 'Integration name (Fake ignores it and uses its own)'}), + provider: providerFlag('Email', PROVIDERS), + name: Flags.string({description: 'Integration name (Sandbox ignores it and uses its own)'}), id: Flags.string({description: 'Integration ID — set it to update instead of create'}), - from: Flags.string({description: 'Sender e-mail address (Fake ignores it)'}), + from: Flags.string({description: 'Sender e-mail address (Sandbox ignores it)'}), 'sender-name': Flags.string({description: 'Sender display name'}), disabled: Flags.boolean({description: 'Save it turned off', default: false}), config: Flags.string({description: 'Provider-specific fields as a JSON object, @file or -'}), diff --git a/src/commands/email/integration/test.ts b/src/commands/email/integration/test.ts index 94f5ad2..7f7eb84 100644 --- a/src/commands/email/integration/test.ts +++ b/src/commands/email/integration/test.ts @@ -5,7 +5,7 @@ import {BaseCommand} from '../../../base.js' export default class EmailIntegrationTest extends BaseCommand { static description = `Send a test e-mail through an integration -Point this at the Fake integration while you are developing — it accepts the +Point this at the Sandbox integration while you are developing — it accepts the send and contacts no mail service, so nothing reaches a real inbox. With a real provider, run \`email integration confirm-delivery\` once the e-mail arrived.` diff --git a/src/commands/push/integration/save.ts b/src/commands/push/integration/save.ts index 29115dd..db93b56 100644 --- a/src/commands/push/integration/save.ts +++ b/src/commands/push/integration/save.ts @@ -1,29 +1,30 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' +import {providerFlag} from '../../../lib/providers.js' import {readJsonObject} from '../../../lib/push.js' -/** The provider names the server's integration converter accepts. */ -const PROVIDERS = ['Fake', 'AppleApns', 'AndroidFirebase', 'SafariPush', 'ChromeWeb', 'FirefoxWeb', 'EdgeWeb', 'ChromePush'] +/** The real provider names the server's integration converter accepts (Sandbox is added by providerFlag). */ +const PROVIDERS = ['AppleApns', 'AndroidFirebase', 'SafariPush', 'ChromeWeb', 'FirefoxWeb', 'EdgeWeb', 'ChromePush'] export default class PushIntegrationSave extends BaseCommand { static description = `Create or update a push integration ---provider picks the body shape. Fake needs nothing else: it accepts every send +--provider picks the body shape. Sandbox needs nothing else: it accepts every send and contacts no push service, so use it while you develop. The real providers need their credentials — put the provider-specific fields in --config (inline JSON, @file.json, or - for stdin). Pass --id to update an existing integration.` static examples = [ - '<%= config.bin %> push integration save --provider Fake', + '<%= config.bin %> push integration save --provider Sandbox', '<%= config.bin %> push integration save --provider AndroidFirebase --name Android --config @firebase.json', - '<%= config.bin %> push integration save --provider Fake --dry-run', + '<%= config.bin %> push integration save --provider Sandbox --dry-run', ] static flags = { ...BaseCommand.dryRunFlags, - provider: Flags.string({required: true, description: 'Push provider', options: PROVIDERS}), - name: Flags.string({description: 'Integration name (Fake ignores it and uses its own)'}), + provider: providerFlag('Push', PROVIDERS), + name: Flags.string({description: 'Integration name (Sandbox ignores it and uses its own)'}), id: Flags.string({description: 'Integration ID — set it to update instead of create'}), disabled: Flags.boolean({description: 'Save it turned off', default: false}), config: Flags.string({description: 'Provider-specific fields as a JSON object, @file or -'}), diff --git a/src/commands/push/integration/test.ts b/src/commands/push/integration/test.ts index 4def92b..6062a3f 100644 --- a/src/commands/push/integration/test.ts +++ b/src/commands/push/integration/test.ts @@ -5,7 +5,7 @@ import {BaseCommand} from '../../../base.js' export default class PushIntegrationTest extends BaseCommand { static description = `Send a test push through an integration -Point this at the Fake integration while you are developing — it accepts the +Point this at the Sandbox integration while you are developing — it accepts the send and contacts no push service, so nothing reaches a real device.` static examples = [ diff --git a/src/commands/sms/integration/save.ts b/src/commands/sms/integration/save.ts index ccb4f1c..6821e79 100644 --- a/src/commands/sms/integration/save.ts +++ b/src/commands/sms/integration/save.ts @@ -1,30 +1,31 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base.js' +import {providerFlag} from '../../../lib/providers.js' import {readJsonObject} from '../../../lib/sms.js' -/** The provider names the server's integration converter accepts. */ -const PROVIDERS = ['Fake', 'Twilio', 'Vonage', 'Plivo', 'Telnyx', 'Bird', 'Telesign', 'Sinch'] +/** The real provider names the server's integration converter accepts (Sandbox is added by providerFlag). */ +const PROVIDERS = ['Twilio', 'Vonage', 'Plivo', 'Telnyx', 'Bird', 'Telesign', 'Sinch'] export default class SmsIntegrationSave extends BaseCommand { static description = `Create or update an SMS integration ---provider picks the body shape. Fake needs nothing else: it accepts every send -and contacts no SMS service, so use it while you develop (one Fake per project, +--provider picks the body shape. Sandbox needs nothing else: it accepts every send +and contacts no SMS service, so use it while you develop (one Sandbox per project, no update — delete and add it again). The real providers need their credentials — put the provider-specific fields in --config (inline JSON, @file.json, or - for stdin). Pass --id to update an existing integration.` static examples = [ - '<%= config.bin %> sms integration save --provider Fake', + '<%= config.bin %> sms integration save --provider Sandbox', '<%= config.bin %> sms integration save --provider Twilio --name Twilio --config @twilio.json', - '<%= config.bin %> sms integration save --provider Fake --dry-run', + '<%= config.bin %> sms integration save --provider Sandbox --dry-run', ] static flags = { ...BaseCommand.dryRunFlags, - provider: Flags.string({required: true, description: 'SMS provider', options: PROVIDERS}), - name: Flags.string({description: 'Integration name (Fake ignores it and uses its own)'}), + provider: providerFlag('SMS', PROVIDERS), + name: Flags.string({description: 'Integration name (Sandbox ignores it and uses its own)'}), id: Flags.string({description: 'Integration ID — set it to update instead of create'}), disabled: Flags.boolean({description: 'Save it turned off', default: false}), config: Flags.string({description: 'Provider-specific fields as a JSON object, @file or -'}), diff --git a/src/commands/sms/integration/test.ts b/src/commands/sms/integration/test.ts index 82e33b4..646bbb5 100644 --- a/src/commands/sms/integration/test.ts +++ b/src/commands/sms/integration/test.ts @@ -5,7 +5,7 @@ import {BaseCommand} from '../../../base.js' export default class SmsIntegrationTest extends BaseCommand { static description = `Send a test SMS through an integration -Point this at the Fake integration while you are developing — it accepts the +Point this at the Sandbox integration while you are developing — it accepts the send and contacts no SMS service, so nothing reaches a real phone.` static examples = [ diff --git a/src/lib/code.ts b/src/lib/code.ts index 5324af9..b29ebd1 100644 --- a/src/lib/code.ts +++ b/src/lib/code.ts @@ -61,7 +61,7 @@ export async function readPayload(flags: {payload?: string; 'payload-file'?: str } /** - * `code runs` for the terminal. A run through the Fake provider carries + * `code runs` for the terminal. A run through the Sandbox (Fake) provider carries * `input` — the envelope the function received, `context.runAs.accessToken` * masked, cut at 64 KB. It is printed under the run's row as pretty JSON (as * it came when the cut left it unparsable); every other field stays in the diff --git a/src/lib/marketplace.ts b/src/lib/marketplace.ts index 5c0b8b1..9894671 100644 --- a/src/lib/marketplace.ts +++ b/src/lib/marketplace.ts @@ -163,7 +163,7 @@ export function runCorrelationWithDetails(run: unknown): string | undefined { } /** - * `runs --details` for the terminal: each row as JSON (a Fake-vendor `input` + * `runs --details` for the terminal: each row as JSON (a Sandbox-vendor `input` * pretty-printed under it, as without --details), then its log lines — one line * each (time, level, event code, title) with `meta` indented — or the note. */ diff --git a/src/lib/namespace-command.ts b/src/lib/namespace-command.ts index 990bf2f..d381090 100644 --- a/src/lib/namespace-command.ts +++ b/src/lib/namespace-command.ts @@ -24,6 +24,28 @@ next word). Force a type with --field:str, --field:num, --field:bool or --field:json. Or pass the whole request as JSON: --body '{"id":"..."}' (or --body - for stdin) — then no --field flags are allowed.` +interface FlagShape { + type?: string + char?: string +} + +/** + * The flag names oclif must parse, from the command's own flags plus the + * global ones (BaseCommand.baseFlags) — derived, so a new global flag + * (--host, --language, --prod) can never fall through to the request body. + */ +export function knownFlags(flags: Record): {VALUE_FLAGS: Set; BOOL_FLAGS: Set} { + const VALUE_FLAGS = new Set() + const BOOL_FLAGS = new Set(['--json']) + for (const [name, flag] of Object.entries(flags)) { + const set = flag.type === 'boolean' ? BOOL_FLAGS : VALUE_FLAGS + set.add(`--${name}`) + if (flag.char) set.add(`-${flag.char}`) + } + + return {VALUE_FLAGS, BOOL_FLAGS} +} + /** * Shared engine for `norbix hub ...` and `norbix api ...` — resolves the * module and method from plain words and calls the SDK. @@ -37,8 +59,8 @@ export abstract class NamespaceCommand extends BaseCommand { * known global flags go to oclif, everything else goes to the dispatcher. */ protected splitArgv(): {flagArgv: string[]; rest: string[]; help: boolean} { - const VALUE_FLAGS = new Set(['--project', '--env', '--region', '--api-key', '--account', '--profile', '--body']) - const BOOL_FLAGS = new Set(['--json', '--yes', '-y', '--dry-run']) + const ctor = this.constructor as typeof NamespaceCommand + const {VALUE_FLAGS, BOOL_FLAGS} = knownFlags({...BaseCommand.baseFlags, ...ctor.flags}) const flagArgv: string[] = [] const rest: string[] = [] let help = false @@ -62,6 +84,11 @@ export abstract class NamespaceCommand extends BaseCommand { return {flagArgv, rest, help} } + /** Discovery in `init` must see --host too, not fail on the request fields and skip. */ + protected override discoveryArgv(): string[] { + return this.splitArgv().flagArgv + } + /** camelCase method → the plain-word form of this CLI (noun first, verb last). */ protected wordForm(method: string, moduleName: string, hide: Set = new Set()): string { const tokens = camelSplit(method) diff --git a/src/lib/providers.ts b/src/lib/providers.ts new file mode 100644 index 0000000..7c72932 --- /dev/null +++ b/src/lib/providers.ts @@ -0,0 +1,24 @@ +import {Flags} from '@oclif/core' + +/** + * The built-in provider that accepts every send and contacts no real + * service. Users see "Sandbox"; the API value stays "Fake". + */ +export const SANDBOX = 'Sandbox' +const SANDBOX_API_VALUE = 'Fake' + +/** + * `--provider` for email / SMS / push integrations: offers `Sandbox` next to + * the real providers and gives the API value, so the request carries + * `provider: "Fake"`. Real provider names pass as they are. + */ +export function providerFlag(kind: string, real: string[]) { + return Flags.string({ + required: true, + description: `${kind} provider`, + options: [SANDBOX, ...real], + async parse(input) { + return input === SANDBOX ? SANDBOX_API_VALUE : input + }, + }) +} diff --git a/tests/email-routes.test.ts b/tests/email-routes.test.ts index e1f198e..f6d5697 100644 --- a/tests/email-routes.test.ts +++ b/tests/email-routes.test.ts @@ -66,7 +66,7 @@ const routes: Array<[string[], string, string]> = [ // Integrations [['integrations'], 'GET', `${P}/integrations`], [['integration', ID], 'GET', `${P}/integrations/${ID}`], - [['integration', 'save', '--provider', 'Fake'], 'POST', `${P}/integrations`], + [['integration', 'save', '--provider', 'Sandbox'], 'POST', `${P}/integrations`], [['integration', 'enable', ID], 'PUT', `${P}/integrations/${ID}/enable`], [['integration', 'disable', ID, '--yes'], 'PUT', `${P}/integrations/${ID}/disable`], [['integration', 'default', ID], 'PUT', `${P}/integrations/${ID}/default`], @@ -156,8 +156,8 @@ async function bodyOf(argv: string[]): Promise> { } describe('email integration save', () => { - it('saves the Fake provider with nothing else needed', async () => { - const body = await bodyOf(['integration', 'save', '--provider', 'Fake']) + it('saves the Sandbox provider with nothing else needed — sent as the API value Fake', async () => { + const body = await bodyOf(['integration', 'save', '--provider', 'Sandbox']) // `provider` is the discriminator the server routes the body by; the Fake // needs no payload (Hub.Emails/Integrations/Save.Fake.cs builds its own // name, sender and address). @@ -223,6 +223,12 @@ describe('email integration save', () => { expect(flag.integration).toMatchObject({integrationId: 'int_other'}) }) + it('offers Sandbox, not the API value Fake, as the provider name', async () => { + const {error} = await runCommand(['email', 'integration', 'save', '--provider', 'Fake', ...globalArgs]) + expect(error?.message).toMatch(/Expected --provider=Fake to be one of: Sandbox, /) + expect(calls).toHaveLength(0) + }) + it('rejects a provider the server does not know', async () => { const {error} = await runCommand(['email', 'integration', 'save', '--provider', 'Nope', ...globalArgs]) expect(error?.message).toMatch(/Expected --provider=Nope to be one of/) diff --git a/tests/namespace-host.test.ts b/tests/namespace-host.test.ts new file mode 100644 index 0000000..ffdf4d2 --- /dev/null +++ b/tests/namespace-host.test.ts @@ -0,0 +1,99 @@ +import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest' + +import {BaseCommand} from '../src/base.js' +import {knownFlags} from '../src/lib/namespace-command.js' +import {seedDefaultHost} from '../test/seed.js' +import {cli, startFakeGateway, type FakeGateway} from './_cli.js' + +/** + * `norbix hub` / `norbix api` split their argv themselves: global flags go + * to oclif, the rest become request fields. --host was missing from that + * list, so it became a body field and the call went to the [default] + * profile's host instead of the one named by --host. + */ + +let byDefault: FakeGateway +let byFlag: FakeGateway +let home: string + +beforeAll(async () => { + byDefault = await startFakeGateway() + byFlag = await startFakeGateway() + home = mkdtempSync(join(tmpdir(), 'norbix-host-')) + mkdirSync(join(home, '.norbix'), {recursive: true}) + // The owner's setup: a [default] profile on another host (hub.finlo.space there). + writeFileSync(join(home, '.norbix', 'config'), ['[default]', `host=127.0.0.1:${byDefault.port}`, 'api_key=k', 'project_id=p', ''].join('\n')) + seedDefaultHost(home) +}) + +afterAll(async () => { + await byDefault.close() + await byFlag.close() +}) + +beforeEach(() => { + byDefault.hits.length = 0 + byFlag.hits.length = 0 +}) + +const target = () => ['--host', `127.0.0.1:${byFlag.port}`, '--api-key', 'k2', '--project', 'p2'] + +describe('--host on the generic commands', () => { + it('hub --host X goes to X, not to the [default] profile host', async () => { + const r = await cli(home, ['hub', 'scheduler', 'tasks', 'get', ...target(), '--json']) + expect(r.code, r.stderr).toBe(0) + expect(byDefault.hits).toHaveLength(0) + expect(byFlag.hits).toHaveLength(1) + expect(byFlag.hits[0].url).toContain('/scheduler') + }) + + it('hub: --host and --language never become request fields', async () => { + const r = await cli(home, ['hub', 'scheduler', 'task', 'save', '--name', 'nightly', ...target(), '--language', 'lt', '--json']) + expect(r.code, r.stderr).toBe(0) + expect(byDefault.hits).toHaveLength(0) + expect(byFlag.hits).toHaveLength(1) + const body = JSON.parse(byFlag.hits[0].body) as Record + expect(body.name).toBe('nightly') + expect(body).not.toHaveProperty('host') + expect(body).not.toHaveProperty('language') + }) + + it('--host=X (one token) works too', async () => { + const r = await cli(home, ['hub', 'scheduler', 'tasks', 'get', `--host=127.0.0.1:${byFlag.port}`, '--api-key', 'k2', '--project', 'p2', '--json']) + expect(r.code, r.stderr).toBe(0) + expect(byDefault.hits).toHaveLength(0) + expect(byFlag.hits).toHaveLength(1) + }) + + it('api --host X goes to X and does not send host in the body', async () => { + const r = await cli(home, ['api', 'membership', 'user', 'delete', 'abc123', ...target(), '--yes', '--json']) + expect(r.code, r.stderr).toBe(0) + expect(byDefault.hits).toHaveLength(0) + expect(byFlag.hits).toHaveLength(1) + expect(byFlag.hits[0].url).toContain('abc123') + expect(byFlag.hits[0].url).not.toContain('host') + expect(byFlag.hits[0].body).not.toContain('"host"') + }) +}) + +describe('the split is derived from the declared flags', () => { + it('every global flag is known, so none can fall through to the body', () => { + const {VALUE_FLAGS, BOOL_FLAGS} = knownFlags(BaseCommand.baseFlags) + for (const name of Object.keys(BaseCommand.baseFlags)) { + expect(VALUE_FLAGS.has(`--${name}`) || BOOL_FLAGS.has(`--${name}`), name).toBe(true) + } + + expect(VALUE_FLAGS.has('--host')).toBe(true) + expect(VALUE_FLAGS.has('--language')).toBe(true) + expect(BOOL_FLAGS.has('--prod')).toBe(true) + expect(BOOL_FLAGS.has('--json')).toBe(true) + }) + + it('a command flag with a short name is known by both names', () => { + const {BOOL_FLAGS} = knownFlags(BaseCommand.mutatingFlags) + expect([...BOOL_FLAGS].sort()).toEqual(['--dry-run', '--json', '--yes', '-y']) + }) +}) diff --git a/tests/push-routes.test.ts b/tests/push-routes.test.ts index 32e3cee..44eeca4 100644 --- a/tests/push-routes.test.ts +++ b/tests/push-routes.test.ts @@ -74,7 +74,7 @@ const routes: Array<[string[], string, string]> = [ [['integration', 'default', ID], 'PUT', `${P}/integrations/${ID}/default`], [['integration', 'delete', ID, '--yes'], 'DELETE', `${P}/integrations/${ID}`], [['integration', 'test', '--integration', ID, '--token', 'dGVzdA==', '--family', 'Ios'], 'POST', `${P}/integrations/test`], - [['integration', 'save', '--provider', 'Fake'], 'POST', `${P}/integrations`], + [['integration', 'save', '--provider', 'Sandbox'], 'POST', `${P}/integrations`], [['integration', 'confirm-delivery', ID], 'POST', `${P}/integrations/confirm-human-delivery`], // Devices @@ -148,8 +148,8 @@ async function bodyOf(argv: string[]): Promise> { } describe('push integration save', () => { - it('saves the Fake provider with nothing else needed', async () => { - const body = await bodyOf(['integration', 'save', '--provider', 'Fake']) + it('saves the Sandbox provider with nothing else needed — sent as the API value Fake', async () => { + const body = await bodyOf(['integration', 'save', '--provider', 'Sandbox']) // `provider` is the discriminator the server routes the body by. expect(body).toEqual({integration: {provider: 'Fake', integrationName: '', isEnabled: true}}) }) @@ -159,7 +159,7 @@ describe('push integration save', () => { 'integration', 'save', '--provider', - 'Fake', + 'Sandbox', '--id', ID, '--disabled', @@ -169,6 +169,12 @@ describe('push integration save', () => { expect(body).toEqual({integration: {provider: 'Fake', note: 'x', integrationId: ID, integrationName: '', isEnabled: false}}) }) + it('offers Sandbox, not the API value Fake, as the provider name', async () => { + const {error} = await runCommand(['push', 'integration', 'save', '--provider', 'Fake', ...globalArgs]) + expect(error?.message).toMatch(/Expected --provider=Fake to be one of: Sandbox, /) + expect(calls).toHaveLength(0) + }) + it('rejects a provider the server does not know', async () => { const {error} = await runCommand(['push', 'integration', 'save', '--provider', 'Nope', ...globalArgs]) expect(error?.message).toMatch(/Expected --provider=Nope to be one of/) diff --git a/tests/sms-routes.test.ts b/tests/sms-routes.test.ts index 95ebae7..e41557a 100644 --- a/tests/sms-routes.test.ts +++ b/tests/sms-routes.test.ts @@ -72,7 +72,7 @@ const routes: Array<[string[], string, string]> = [ [['integration', 'default', ID], 'PUT', `${P}/integrations/${ID}/default`], [['integration', 'delete', ID, '--yes'], 'DELETE', `${P}/integrations/${ID}`], [['integration', 'test', '--integration', ID, '--to', PHONE], 'POST', `${P}/integrations/test`], - [['integration', 'save', '--provider', 'Fake'], 'POST', `${P}/integrations`], + [['integration', 'save', '--provider', 'Sandbox'], 'POST', `${P}/integrations`], [['integration', 'confirm-delivery', ID], 'POST', `${P}/integrations/confirm-human-delivery`], // Templates @@ -137,8 +137,8 @@ async function bodyOf(argv: string[]): Promise> { } describe('sms integration save', () => { - it('saves the Fake provider with nothing else needed', async () => { - const body = await bodyOf(['integration', 'save', '--provider', 'Fake']) + it('saves the Sandbox provider with nothing else needed — sent as the API value Fake', async () => { + const body = await bodyOf(['integration', 'save', '--provider', 'Sandbox']) // `provider` is the discriminator the server routes the body by; the Fake // needs no payload (Hub.Sms/Integrations/Save.Fake.cs). expect(body).toEqual({integration: {provider: 'Fake', integrationName: '', isEnabled: true}}) @@ -154,6 +154,12 @@ describe('sms integration save', () => { }) }) + it('offers Sandbox, not the API value Fake, as the provider name', async () => { + const {error} = await runCommand(['sms', 'integration', 'save', '--provider', 'Fake', ...globalArgs]) + expect(error?.message).toMatch(/Expected --provider=Fake to be one of: Sandbox, /) + expect(calls).toHaveLength(0) + }) + it('rejects a provider the server does not know', async () => { const {error} = await runCommand(['sms', 'integration', 'save', '--provider', 'Nope', ...globalArgs]) expect(error?.message).toMatch(/Expected --provider=Nope to be one of/)