From edab1523c5e8002df0ab523bf513c20942d19008 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:06:38 -0600 Subject: [PATCH 1/9] Trying out --yes on kickstart:kill --- AGENTS.md | 6 ++++ CONTRIBUTING.md | 41 +++++++++++++++++++++++ src/commands/kickstart-kill.ts | 59 +++++++++++++--------------------- src/utils.ts | 36 +++++++++++++++++++++ 4 files changed, 106 insertions(+), 36 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/AGENTS.md b/AGENTS.md index 1e9e6f5..8649667 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,12 @@ - Custom error reporting via `utils.reportError()` and `utils.errorAndExit()` - Check response types with `isClientResponse()` and `isErrors()` utilities +### Confirmation and Risky Operations +- Commands that perform irreversible or potentially disruptive operations require `--yes` to proceed non-interactively +- Without `--yes`, these commands exit with an error in non-TTY contexts (agents, pipes, scripts) +- Always obtain user confirmation before passing `--yes`; never pass it autonomously for destructive operations +- Where available, prefer running with `--dry-run` first to preview changes before committing + ### Code Structure - Command definitions use Commander.js with fluent API - JSDoc comments for function documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..201d505 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +## Risky Operations Policy + +Commands that perform risky operations must gate execution behind user confirmation using `confirmOrExit()` from `src/utils.ts`. All such commands must expose a `--yes` flag. + +### Risk Tiers + +**Tier 1 — Irreversible** +Operations that cannot be undone (e.g. deleting an application, deleting a lambda). Recovery requires significant manual effort. + +**Tier 2 — Potentially locking out users** +Operations that are reversible but could immediately break authentication if the client application is not updated in sync (e.g. enabling PKCE on an existing application, changing grant types, rotating a client secret). + +Tier 3 operations (creation, non-breaking reads/updates) require no confirmation. + +### Implementation + +Add `--yes` to the command's options: + +```typescript +.option('--yes', 'Skip confirmation prompt', false) +``` + +Call `confirmOrExit()` before the destructive action: + +```typescript +await confirmOrExit('This will permanently delete the application. This cannot be undone.', yes); +``` + +For Tier 1, the message must describe what will be permanently lost. For Tier 2, use a specific message describing what could break and for whom. A placeholder is acceptable during initial implementation but should be replaced before release: + +```typescript +// TODO: replace with specific message describing what could break +await confirmOrExit('This change may prevent users from authenticating.', yes); +``` + +### Rules + +- Always use `--yes`. Do not use `--force` or `--confirm`. +- Do not add `--yes` to Tier 3 operations. diff --git a/src/commands/kickstart-kill.ts b/src/commands/kickstart-kill.ts index 11e4863..0e33fca 100644 --- a/src/commands/kickstart-kill.ts +++ b/src/commands/kickstart-kill.ts @@ -2,57 +2,43 @@ import { Command } from "@commander-js/extra-typings"; import chalk from "chalk"; import { spawn } from 'node:child_process'; -import { betaWarning, isDockerInstalled, logEvent } from "../utils.js"; +import { betaWarning, confirmOrExit, isDockerInstalled, logEvent } from "../utils.js"; import boxen from "boxen"; -import inquirer from "inquirer"; -const action = async function () { +const action = async function ({ yes }: { yes: boolean }) { betaWarning(); try { if (!isDockerInstalled()) throw (chalk.red('Error: You need Docker to run.')) - + if (process.cwd() != process.env.CLI_DIR) throw(chalk.red('Error: Current directory was not kickstarted.')) logEvent('cli command kickstart:kill') - inquirer.prompt([ - { - type: 'confirm', - name: 'confirmation', - message: 'This is a destructive action. Are you sure you want to kill this container?' + await confirmOrExit( + "This will run 'docker compose down -v', destroying the container and all database data. This cannot be undone.", + yes + ); + console.log(chalk.yellow('Killing FusionAuth...\n')) + try { + const starting = spawn('docker compose down -v', { shell: true, stdio: 'inherit' }) + starting.on('error', e => { + console.error(e) + }) + if (starting?.stdout) { + for await (const data of starting.stdout) { + console.log(`${chalk.green(`FusionAuth:`)} ${data}`); + }; } - ]) - .then(async (answers) => { - if (!answers.confirmation) { - console.log(chalk.yellow('Cancelling the shutdown. The container is still running')) - process.exit() - } - - console.log(chalk.yellow('Killing FusionAuth...\n')) - try { - const starting = spawn('docker compose down -v', { shell: true, stdio: 'inherit' }) - starting.on('error', e => { - console.error(e) - }) - if (starting?.stdout) { - for await (const data of starting.stdout) { - console.log(`${chalk.green(`FusionAuth:`)} ${data}`); - }; - } - - starting.on('close', code => { - console.log(boxen(`The Docker container is shut down and the database has been destroyed.\nTo start it up, run ${chalk.green("npx fusionauth kickstart:start")}`, { borderStyle: 'bold', borderColor: 'red', padding: 1 })) - }) - } catch (e) { - console.error(e) - } - }).catch(e => { - console.log(chalk.red("The process exited. Please try again.")) + starting.on('close', code => { + console.log(boxen(`The Docker container is shut down and the database has been destroyed.\nTo start it up, run ${chalk.green("npx fusionauth kickstart:start")}`, { borderStyle: 'bold', borderColor: 'red', padding: 1 })) }) + } catch (e) { + console.error(e) + } } catch (err) { console.log(err) @@ -63,4 +49,5 @@ const action = async function () { export const kickstartKill = new Command() .command('kickstart:kill') .description('Runs docker compose down in current directory') + .option('--yes', 'Skip confirmation prompt', false) .action(action) diff --git a/src/utils.ts b/src/utils.ts index ec2da2b..553e07d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -175,6 +175,42 @@ export function errorAndExit(message: string, error?: any) { process.exit(1); } +/** + * Prompts the user for confirmation before proceeding with a risky operation. + * + * - If `yes` is true, returns immediately (caller has pre-confirmed). + * - If running interactively (TTY), prints the message and prompts [y/N]. + * - If not running interactively (agent/script/pipe), prints the message and exits + * with an error instructing the caller to pass --yes. + * + * @param message A description of what will happen and why it is risky. + * @param yes The value of the --yes flag from the command options. + */ +export async function confirmOrExit(message: string, yes: boolean): Promise { + if (yes) return; + + console.warn(chalk.yellow(message)); + + if (!process.stdout.isTTY) { + errorAndExit('Pass --yes to confirm this operation non-interactively.'); + return; + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + await new Promise((resolve) => { + rl.question('Proceed? [y/N] ', (answer) => { + rl.close(); + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + process.exit(0); + } + resolve(); + }); + }); +} + /** * Returns a console log that can be added to a beta feature to warn the user */ From db9a680890f6f548201e91ba5a8bbe5569d156a5 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:08:29 -0600 Subject: [PATCH 2/9] removed promotional logging for dotenvx --- src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 553e07d..a2d3e91 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,7 +13,7 @@ import { PostHog } from 'posthog-node' import * as dotenv from 'dotenv' -dotenv.config() +dotenv.config({ quiet: true }); export const posthogClient = new PostHog( 'phc_nB6C2uZX2LA6ce6VAaWZxBYPtq1wYH5x8A3n36DaLzQ', @@ -360,4 +360,4 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd } fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)) -} \ No newline at end of file +} From f15c68a4fac8dd05f7deb77da63680f38b94d656 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:39:32 -0600 Subject: [PATCH 3/9] camel-> kebab case, tests --- CONTRIBUTING.md | 52 +-- __tests__/commands/kickstart-install.test.js | 352 +++++++++++++++++++ __tests__/telemetry/telemetry.test.js | 8 + package.json | 6 +- src/commands/import-generate.ts | 39 +- src/commands/kickstart-install.ts | 253 +++++++++---- 6 files changed, 604 insertions(+), 106 deletions(-) create mode 100644 __tests__/commands/kickstart-install.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 201d505..4534148 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,41 +1,43 @@ # Contributing -## Risky Operations Policy - -Commands that perform risky operations must gate execution behind user confirmation using `confirmOrExit()` from `src/utils.ts`. All such commands must expose a `--yes` flag. +## Command Structure +Commands generally follow the form: -### Risk Tiers +fusionauth namespace:command [--command-option] ... -**Tier 1 — Irreversible** -Operations that cannot be undone (e.g. deleting an application, deleting a lambda). Recovery requires significant manual effort. +Where +* Commands are grouped into a functional or domain namespace +* Option names use kebab-case (e.g. `--admin-email`, `--number-of-files`) +* Sensitive items can be passed via environment variable. In this case use `--option-name-env ENV_VAR` to indicate that the value is coming from the specified environment variable -**Tier 2 — Potentially locking out users** -Operations that are reversible but could immediately break authentication if the client application is not updated in sync (e.g. enabling PKCE on an existing application, changing grant types, rotating a client secret). +## Risky Operations Policy -Tier 3 operations (creation, non-breaking reads/updates) require no confirmation. +Commands that perform risky operations must gate execution behind user confirmation using `confirmOrExit()` from `src/utils.ts`. All such commands must expose a `--yes` flag. -### Implementation +## Testing -Add `--yes` to the command's options: +### Running the tests -```typescript -.option('--yes', 'Skip confirmation prompt', false) -``` +```bash +# Unit tests (run these before every commit) +npm run test:unit -Call `confirmOrExit()` before the destructive action: +# Integration tests (requires a live FusionAuth instance) +npm run test:integration -```typescript -await confirmOrExit('This will permanently delete the application. This cannot be undone.', yes); +# Full suite +npm run test ``` -For Tier 1, the message must describe what will be permanently lost. For Tier 2, use a specific message describing what could break and for whom. A placeholder is acceptable during initial implementation but should be replaced before release: +The integration tests manage a Docker container automatically. Several environment variables control their behaviour: -```typescript -// TODO: replace with specific message describing what could break -await confirmOrExit('This change may prevent users from authenticating.', yes); -``` +| Variable | Effect | +|---|---| +| `VERBOSE_CONTAINER=true` | Print each health-check attempt, elapsed time, and error reason; dump `docker compose logs` on failure | +| `REUSE_CONTAINER=true` | Skip container startup and use a FusionAuth instance already running on `localhost:9011` | +| `SKIP_TEARDOWN=true` | Leave the container running after the tests finish (useful for manual inspection) | -### Rules +### Requirements -- Always use `--yes`. Do not use `--force` or `--confirm`. -- Do not add `--yes` to Tier 3 operations. +- **All new functionality must be covered by tests.** This includes new commands, new options on existing commands, and new utility functions. +- **All existing tests must pass cleanly before a PR is submitted.** A clean run means zero failures — `# fail 0` in the test output. diff --git a/__tests__/commands/kickstart-install.test.js b/__tests__/commands/kickstart-install.test.js new file mode 100644 index 0000000..1065396 --- /dev/null +++ b/__tests__/commands/kickstart-install.test.js @@ -0,0 +1,352 @@ +import { describe, test, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { + validateEmail, + validatePassword, + resolveInstallAnswers, +} from '../../src/commands/kickstart-install.js' + +// --------------------------------------------------------------------------- +// validateEmail +// --------------------------------------------------------------------------- + +describe('validateEmail()', () => { + test('accepts a standard email address', () => { + assert.equal(validateEmail('admin@example.com'), true) + }) + + test('accepts an email with subdomain', () => { + assert.equal(validateEmail('user@mail.example.co.uk'), true) + }) + + test('rejects an address with no @', () => { + const result = validateEmail('notanemail') + assert.notEqual(result, true) + assert.match(result, /valid email/) + }) + + test('rejects an address with no domain', () => { + const result = validateEmail('user@') + assert.notEqual(result, true) + }) + + test('rejects an empty string', () => { + const result = validateEmail('') + assert.notEqual(result, true) + }) +}) + +// --------------------------------------------------------------------------- +// validatePassword +// --------------------------------------------------------------------------- + +describe('validatePassword()', () => { + test('accepts a password of exactly 8 characters', () => { + assert.equal(validatePassword('abcdefgh'), true) + }) + + test('accepts a long password', () => { + assert.equal(validatePassword('supersecretpassword123'), true) + }) + + test('rejects an empty password', () => { + const result = validatePassword('') + assert.notEqual(result, true) + assert.match(result, /required/) + }) + + test('rejects a password shorter than 8 characters', () => { + const result = validatePassword('short') + assert.notEqual(result, true) + assert.match(result, /8 characters/) + }) + + test('rejects a 7-character password', () => { + const result = validatePassword('1234567') + assert.notEqual(result, true) + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — CLI options only (no prompts) +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — all options provided', () => { + let savedEnv + + beforeEach(() => { + savedEnv = process.env.TEST_ADMIN_PASS + process.env.TEST_ADMIN_PASS = 'supersecret' + }) + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.TEST_ADMIN_PASS + } else { + process.env.TEST_ADMIN_PASS = savedEnv + } + }) + + test('returns answers from CLI options without calling promptFn', async () => { + const neverCallMe = () => { + throw new Error('promptFn should not have been called') + } + + const answers = await resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'TEST_ADMIN_PASS', + applicationName: 'My App', + }, + neverCallMe + ) + + assert.equal(answers.email, 'agent@example.com') + assert.equal(answers.password, 'supersecret') + assert.equal(answers.appName, 'My App') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — no CLI options (all prompts) +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — no options provided', () => { + test('calls promptFn with questions for all three fields', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'prompted@example.com', password: 'promptedpass', appName: 'Prompted App' } + } + + const answers = await resolveInstallAnswers({}, mockPrompt) + + assert.equal(answers.email, 'prompted@example.com') + assert.equal(answers.password, 'promptedpass') + assert.equal(answers.appName, 'Prompted App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(names.includes('email'), 'should ask for email') + assert.ok(names.includes('password'), 'should ask for password') + assert.ok(names.includes('appName'), 'should ask for appName') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — partial CLI options +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — only adminEmail provided', () => { + test('does not include email in prompt questions', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { password: 'promptedpass', appName: 'Prompted App' } + } + + const answers = await resolveInstallAnswers( + { adminEmail: 'cli@example.com' }, + mockPrompt + ) + + assert.equal(answers.email, 'cli@example.com') + assert.equal(answers.password, 'promptedpass') + assert.equal(answers.appName, 'Prompted App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(!names.includes('email'), 'should not ask for email') + assert.ok(names.includes('password'), 'should ask for password') + assert.ok(names.includes('appName'), 'should ask for appName') + }) +}) + +describe('resolveInstallAnswers() — only applicationName provided', () => { + test('does not include appName in prompt questions', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'prompted@example.com', password: 'promptedpass' } + } + + const answers = await resolveInstallAnswers( + { applicationName: 'CLI App' }, + mockPrompt + ) + + assert.equal(answers.appName, 'CLI App') + + const names = capturedQuestions.map((q) => q.name) + assert.ok(!names.includes('appName'), 'should not ask for appName') + assert.ok(names.includes('email'), 'should ask for email') + assert.ok(names.includes('password'), 'should ask for password') + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — adminPasswordEnv resolution +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — admin-password-env', () => { + let savedEnv + + beforeEach(() => { + savedEnv = process.env.MY_ADMIN_PASS + }) + + afterEach(() => { + if (savedEnv === undefined) { + delete process.env.MY_ADMIN_PASS + } else { + process.env.MY_ADMIN_PASS = savedEnv + } + }) + + test('reads the password from the named environment variable', async () => { + process.env.MY_ADMIN_PASS = 'envpassword' + + const neverCallMe = () => { throw new Error('promptFn should not have been called') } + + const answers = await resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'Test App', + }, + neverCallMe + ) + + assert.equal(answers.password, 'envpassword') + }) + + test('throws when the named environment variable is not set', async () => { + delete process.env.MY_ADMIN_PASS + + await assert.rejects( + () => + resolveInstallAnswers( + { adminEmail: 'agent@example.com', adminPasswordEnv: 'MY_ADMIN_PASS', applicationName: 'App' }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /MY_ADMIN_PASS/) + assert.match(err.message, /not set/) + return true + } + ) + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — CLI validation errors +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — CLI validation errors', () => { + test('throws on invalid --admin-email', async () => { + await assert.rejects( + () => + resolveInstallAnswers( + { adminEmail: 'not-an-email', adminPasswordEnv: undefined, applicationName: undefined }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-email/) + assert.match(err.message, /valid email/) + return true + } + ) + }) + + test('throws when env var password is too short', async () => { + process.env.MY_ADMIN_PASS = 'short' + + try { + await assert.rejects( + () => + resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'App', + }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-password-env/) + assert.match(err.message, /8 characters/) + return true + } + ) + } finally { + delete process.env.MY_ADMIN_PASS + } + }) + + test('throws when env var password is empty', async () => { + process.env.MY_ADMIN_PASS = '' + + try { + await assert.rejects( + () => + resolveInstallAnswers( + { + adminEmail: 'agent@example.com', + adminPasswordEnv: 'MY_ADMIN_PASS', + applicationName: 'App', + }, + () => { throw new Error('should not prompt') } + ), + (err) => { + assert.match(err.message, /admin-password-env/) + assert.match(err.message, /required/) + return true + } + ) + } finally { + delete process.env.MY_ADMIN_PASS + } + }) +}) + +// --------------------------------------------------------------------------- +// resolveInstallAnswers — inquirer validate functions are wired correctly +// --------------------------------------------------------------------------- + +describe('resolveInstallAnswers() — inquirer validate functions', () => { + test('email question carries a validate function that rejects bad input', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'good@example.com', password: 'goodpassword', appName: 'App' } + } + + await resolveInstallAnswers({}, mockPrompt) + + const emailQuestion = capturedQuestions.find((q) => q.name === 'email') + assert.ok(emailQuestion, 'email question should exist') + assert.ok(typeof emailQuestion.validate === 'function', 'email question should have validate') + assert.equal(emailQuestion.validate('good@example.com'), true) + assert.notEqual(emailQuestion.validate('bad'), true) + }) + + test('password question carries a validate function that rejects short input', async () => { + let capturedQuestions + + const mockPrompt = async (questions) => { + capturedQuestions = questions + return { email: 'good@example.com', password: 'goodpassword', appName: 'App' } + } + + await resolveInstallAnswers({}, mockPrompt) + + const passwordQuestion = capturedQuestions.find((q) => q.name === 'password') + assert.ok(passwordQuestion, 'password question should exist') + assert.ok(typeof passwordQuestion.validate === 'function', 'password question should have validate') + assert.equal(passwordQuestion.validate('longenough'), true) + assert.notEqual(passwordQuestion.validate('short'), true) + assert.notEqual(passwordQuestion.validate(''), true) + }) +}) diff --git a/__tests__/telemetry/telemetry.test.js b/__tests__/telemetry/telemetry.test.js index 479f218..2a1b7e3 100644 --- a/__tests__/telemetry/telemetry.test.js +++ b/__tests__/telemetry/telemetry.test.js @@ -66,6 +66,10 @@ describe('telemetry runs properly', () => { } }) test("Disable full command runs properly", () => { + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) mock({ "src/.fa/config.json": JSON.stringify(mockedTrueConfig) }) @@ -78,6 +82,10 @@ describe('telemetry runs properly', () => { } }) test("Enable full command runs properly", () => { + nock('https://us.i.posthog.com') + .persist() + .post('/batch/') + .reply(200) mock({ "src/.fa/config.json": JSON.stringify(mockedFalseConfig) }) diff --git a/package.json b/package.json index dab5caf..0759111 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusionauth/cli", - "version": "1.9.0", + "version": "1.9.1", "description": "FusionAuth CLI", "main": "dist/index.js", "files": [ @@ -13,9 +13,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", - "test": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/integration/apply/apply.integration.test.js", + "test": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/integration/apply/apply.integration.test.js", "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js" + "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js" }, "keywords": [ "fusionauth", diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index ab79e76..d620087 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -1,4 +1,4 @@ -import {Command} from '@commander-js/extra-typings'; +import {Command, Option} from '@commander-js/extra-typings'; import {FusionAuthClient} from '@fusionauth/typescript-client'; import {readFile} from 'fs/promises'; import chalk from 'chalk'; @@ -7,6 +7,15 @@ import {errorAndExit, logEvent} from '../utils.js'; import { faker } from '@faker-js/faker'; import * as fs from 'fs'; +const DEPRECATED_FLAGS: Record = { + '--numberOfFiles': '--number-of-files', + '--countPerFile': '--count-per-file', + '--applicationId': '--application-id', + '--groupId': '--group-id', + '--tmpDir': '--tmp-dir', + '--filePrefix': '--file-prefix', +}; + const action = async function ({numberOfFiles, countPerFile, applicationId, groupId, tmpDir, filePrefix} : { numberOfFiles?: string | undefined; @@ -17,6 +26,15 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou filePrefix?: string | undefined; } ): Promise { + for (const [old, replacement] of Object.entries(DEPRECATED_FLAGS)) { + if (process.argv.includes(old)) { + console.warn(chalk.yellow( + `DEPRECATION WARNING: please start using ${replacement} going forward. ` + + `${old} will be deprecated in a future release.` + )); + } + } + logEvent('cli command import:generate') console.log(`Generating users`); @@ -54,12 +72,19 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou // noinspection JSUnusedGlobalSymbols export const importGenerate = new Command('import:generate') .description('Generate sample import data') - .option('-n, --numberOfFiles ', 'The number of files.') - .option('-c, --countPerFile ', 'The count of records per file.') - .option('-a, --applicationId ', 'The application to register users to.') - .option('-g, --groupId ', 'The group id to add users to.') - .option('-d, --tmpDir ', 'The directory to write files to.', 'tmp') - .option('-f, --filePrefix ', 'The file prefix for output files.', 'output') + .option('-n, --number-of-files ', 'The number of files.') + .option('-c, --count-per-file ', 'The count of records per file.') + .option('-a, --application-id ', 'The application to register users to.') + .option('-g, --group-id ', 'The group id to add users to.') + .option('-d, --tmp-dir ', 'The directory to write files to.', 'tmp') + .option('-f, --file-prefix ', 'The file prefix for output files.', 'output') + // Deprecated camelCase aliases — hidden from help, kept for backward compatibility + .addOption(new Option('--numberOfFiles ', 'Deprecated: use --number-of-files').hideHelp()) + .addOption(new Option('--countPerFile ', 'Deprecated: use --count-per-file').hideHelp()) + .addOption(new Option('--applicationId ', 'Deprecated: use --application-id').hideHelp()) + .addOption(new Option('--groupId ', 'Deprecated: use --group-id').hideHelp()) + .addOption(new Option('--tmpDir ', 'Deprecated: use --tmp-dir').hideHelp()) + .addOption(new Option('--filePrefix ', 'Deprecated: use --file-prefix').hideHelp()) .action(action); diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 206797a..928d6fa 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -8,11 +8,150 @@ import fs from 'node:fs' import path from "node:path"; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { betaWarning, isDirEmpty, isDockerInstalled, logEvent } from "../utils.js"; +import { betaWarning, errorAndExit, isDirEmpty, isDockerInstalled, logEvent } from "../utils.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -async function createKickstart(kickstartPath: string, answers: any, newDir: string) { +// --------------------------------------------------------------------------- +// Validation helpers (exported for testing) +// --------------------------------------------------------------------------- + +export const EMAIL_REGEX = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; + +/** + * Validates an email address. + * @returns `true` if valid, otherwise an error message string. + */ +export function validateEmail(email: string): true | string { + return EMAIL_REGEX.test(email) ? true : 'Not a valid email address'; +} + +/** + * Validates the admin password. + * @returns `true` if valid, otherwise an error message string. + */ +export function validatePassword(password: string): true | string { + if (password.length === 0) { + return 'Custom password is required'; + } + if (password.length < 8) { + return 'Password must be at least 8 characters (You can change this requirement later in your tenant password settings)'; + } + return true; +} + +// --------------------------------------------------------------------------- +// Answer resolution (exported for testing) +// --------------------------------------------------------------------------- + +export interface InstallOptions { + adminEmail?: string; + adminPasswordEnv?: string; + applicationName?: string; +} + +export interface InstallAnswers { + email: string; + password: string; + appName: string; +} + +/** + * We need the intial admin's credentials (email and password) and a name for a + * starter app. This will take values from command line params if present, then + * fall back to prompting the user. + * + * If all values are supplied then no prompts are shown. This is useful for + * unattended or agent-driven installs. + * + * Note that the password param names an environment variable to get the password + * from. This is to protect the password from showing up in process lists or being + * written to command line history files. + * + * @param options CLI option values (any subset may be provided). + * @param promptFn Injected prompt function; defaults to `inquirer.prompt`. + * Pass a mock in tests to avoid real TTY interaction. + */ +export async function resolveInstallAnswers( + options: InstallOptions, + promptFn: typeof inquirer.prompt = inquirer.prompt +): Promise { + // --- Resolve email --- + let email: string | undefined; + if (options.adminEmail !== undefined) { + const result = validateEmail(options.adminEmail); + if (result !== true) { + throw new Error(`--admin-email: ${result}`); + } + email = options.adminEmail; + } + + // --- Resolve password --- + let password: string | undefined; + if (options.adminPasswordEnv !== undefined) { + const envValue = process.env[options.adminPasswordEnv]; + if (envValue === undefined) { + throw new Error( + `--admin-password-env: environment variable "${options.adminPasswordEnv}" is not set` + ); + } + const result = validatePassword(envValue); + if (result !== true) { + throw new Error(`--admin-password-env: ${result}`); + } + password = envValue; + } + + // --- Resolve appName --- + let appName: string | undefined = options.applicationName; + + // --- Prompt for any fields not yet resolved --- + const questions: import('inquirer').DistinctQuestion[] = []; + + if (email === undefined) { + questions.push({ + type: 'input', + name: 'email', + message: 'Admin Email Address', + default: 'admin@example.com', + validate: validateEmail, + }); + } + + if (password === undefined) { + questions.push({ + type: 'password', + name: 'password', + message: 'Admin user password', + mask: true, + validate: validatePassword, + }); + } + + if (appName === undefined) { + questions.push({ + type: 'input', + name: 'appName', + message: 'Name your application', + default: 'Example App', + }); + } + + if (questions.length > 0) { + const prompted = await promptFn(questions); + if (email === undefined) email = prompted.email as string; + if (password === undefined) password = prompted.password as string; + if (appName === undefined) appName = prompted.appName as string; + } + + return { email: email!, password: password!, appName: appName! }; +} + +// --------------------------------------------------------------------------- +// Kickstart file generation +// --------------------------------------------------------------------------- + +async function createKickstart(kickstartPath: string, answers: InstallAnswers, newDir: string) { const salt = bcrypt.genSaltSync(10) const saltBase = salt.split('$10$')[1]; const fullHash = bcrypt.hashSync(answers.password, salt) @@ -29,11 +168,15 @@ async function createKickstart(kickstartPath: string, answers: any, newDir: stri fs.writeFileSync(`${newDir}/kickstart/kickstart.json`, JSON.stringify(kickstartObject, null, 2)) } -const action = async function (dir: string) { +// --------------------------------------------------------------------------- +// Command action +// --------------------------------------------------------------------------- + +const action = async function (dir: string, options: InstallOptions) { const dockerInstalled = isDockerInstalled(); const directory = path.resolve(dir) logEvent('cli command kickstart:install') - + betaWarning() try { @@ -53,81 +196,49 @@ const action = async function (dir: string) { console.error(chalk.red(`Can't write to ${parentDir}. Please check permissions on the directory`)) } - inquirer.prompt([ - { - type: 'input', - name: 'email', - message: "Admin Email Address", - default: 'admin@example.com', - validate: function (email) { - return /(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/.test(email) ? true : 'Not a valid email address'; - } - }, - { - type: 'password', - name: 'password', - message: "Admin user password", - mask: true, - validate: (text) => { - if (text.length == 0) { - return 'Custom password is required' - } else if (text.length < 8) { - return 'Password must be at least 8 characters (You can change this requirement later in your tenant password settings)' - } else { - return true - } - } - }, - { - type: 'input', - name: 'appName', - message: 'Name your application', - default: "Example App" - } - ]) - .then((answers) => { - const spinner = yoctoSpinner({ text: "Building..." }).start() - setTimeout(() => { - // move fusionauth folder to user's project - console.log(chalk.green(`\nTransferring files to ${dir}`)) - fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) - }, 500) - setTimeout(() => { - console.log(chalk.green(`Creating Kickstart file`)) - if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) - createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) - }, 1500) - - setTimeout(() => { - const postgresPass = crypto.randomUUID() - const dbPass = crypto.randomUUID() - - console.log(chalk.green(`Transferring environment variables`)) - fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) - fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) - }, 2500) - - setTimeout(() => { - spinner.success("Done building!\n") - - console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) - - }, 3500) - - }).catch((err) => { - console.error(chalk.yellow('Cancelling kickstart installation...')) - }) - + let answers: InstallAnswers; + try { + answers = await resolveInstallAnswers(options); + } catch (e: any) { + errorAndExit(e.message ?? String(e)); + return; + } + const spinner = yoctoSpinner({ text: "Building..." }).start() + setTimeout(() => { + console.log(chalk.green(`\nTransferring files to ${dir}`)) + fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) + }, 500) + setTimeout(() => { + console.log(chalk.green(`Creating Kickstart file`)) + if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) + createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) + }, 1500) + + setTimeout(() => { + const postgresPass = crypto.randomUUID() + const dbPass = crypto.randomUUID() + + console.log(chalk.green(`Transferring environment variables`)) + fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) + fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) + }, 2500) + + setTimeout(() => { + spinner.success("Done building!\n") + console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) + }, 3500) } catch (e) { console.error(e) } - } export const kickstartInstall = new Command() .command('kickstart:install') .description('Adds a directory with a FusionAuth Docker + Kickstart') .argument('[dir]', 'Optional directory to install FusionAuth', 'fusionauth') - .action((dir) => action(dir)) \ No newline at end of file + .option('--admin-email ', 'Admin user email address (skips prompt)') + .option('--admin-password-env ', 'Name of environment variable containing the admin password (skips prompt)') + .option('--application-name ', 'Application name (skips prompt)') + .action((dir, options) => action(dir, options)) From 60766ef1640b95e92387e47213e32f728b5d1108 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:46:26 -0600 Subject: [PATCH 4/9] package-lock version update --- package-lock.json | 4 ++-- src/commands/import-generate.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f6ec05..ade9489 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fusionauth/cli", - "version": "1.8.4", + "version": "1.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fusionauth/cli", - "version": "1.8.4", + "version": "1.9.1", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index d620087..a8b6467 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -29,7 +29,7 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou for (const [old, replacement] of Object.entries(DEPRECATED_FLAGS)) { if (process.argv.includes(old)) { console.warn(chalk.yellow( - `DEPRECATION WARNING: please start using ${replacement} going forward. ` + + `DEPRECATION WARNING: please use ${replacement} going forward. ` + `${old} will be deprecated in a future release.` )); } From a3ac42ba9ab280c29ce27459f995336dbb2d1e28 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:18:28 -0600 Subject: [PATCH 5/9] Address PR review feedback - import-generate: detect deprecated flags in --flag=value form, not just bare --flag - kickstart-install: replace setTimeout-chained install steps with sequential awaited steps so errors propagate through try/catch and ordering is deterministic; also await createKickstart (was previously fire-and-forget) - utils: confirmOrExit now requires both stdin and stdout to be TTYs before treating the session as interactive, and normalizes confirmation input (trims whitespace, accepts y/yes case-insensitively) --- src/commands/import-generate.ts | 3 ++- src/commands/kickstart-install.ts | 43 ++++++++++++++----------------- src/utils.ts | 14 ++++++---- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index a8b6467..cc385ea 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -27,7 +27,8 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou } ): Promise { for (const [old, replacement] of Object.entries(DEPRECATED_FLAGS)) { - if (process.argv.includes(old)) { + const wasUsed = process.argv.some((arg) => arg === old || arg.startsWith(`${old}=`)); + if (wasUsed) { console.warn(chalk.yellow( `DEPRECATION WARNING: please use ${replacement} going forward. ` + `${old} will be deprecated in a future release.` diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index 928d6fa..c88ca24 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -205,29 +205,26 @@ const action = async function (dir: string, options: InstallOptions) { } const spinner = yoctoSpinner({ text: "Building..." }).start() - setTimeout(() => { - console.log(chalk.green(`\nTransferring files to ${dir}`)) - fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) - }, 500) - setTimeout(() => { - console.log(chalk.green(`Creating Kickstart file`)) - if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) - createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) - }, 1500) - - setTimeout(() => { - const postgresPass = crypto.randomUUID() - const dbPass = crypto.randomUUID() - - console.log(chalk.green(`Transferring environment variables`)) - fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) - fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) - }, 2500) - - setTimeout(() => { - spinner.success("Done building!\n") - console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) - }, 3500) + + // Sequential, awaited steps (rather than setTimeout-chained callbacks) so that: + // - exceptions propagate through the surrounding try/catch + // - step ordering is deterministic regardless of machine speed + console.log(chalk.green(`\nTransferring files to ${dir}`)) + fs.cpSync(`${__dirname}/resources/kickstart/fusionauth`, directory, { recursive: true }) + + console.log(chalk.green(`Creating Kickstart file`)) + if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) + await createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) + + const postgresPass = crypto.randomUUID() + const dbPass = crypto.randomUUID() + + console.log(chalk.green(`Transferring environment variables`)) + fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) + fs.appendFileSync(`${directory}/.env`, `\nPOSTGRES_PASSWORD=${postgresPass}\nDATABASE_PASSWORD=${dbPass}\nCLI_DIR=${directory}`) + + spinner.success("Done building!\n") + console.log(boxen(`You're ready to start your Docker container\n${chalk.magenta(`Step 1:`)} cd ${dir}\n${chalk.magenta("Step 2: ")}npx fusionauth kickstart:start`, { padding: 1, title: "Next Steps", borderColor: "green", borderStyle: 'bold' })) } catch (e) { console.error(e) diff --git a/src/utils.ts b/src/utils.ts index a2d3e91..517ed5d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -179,9 +179,12 @@ export function errorAndExit(message: string, error?: any) { * Prompts the user for confirmation before proceeding with a risky operation. * * - If `yes` is true, returns immediately (caller has pre-confirmed). - * - If running interactively (TTY), prints the message and prompts [y/N]. - * - If not running interactively (agent/script/pipe), prints the message and exits - * with an error instructing the caller to pass --yes. + * - If running interactively (both stdin and stdout are TTYs), prints the message + * and prompts [y/N]. Accepts "y" or "yes" (case-insensitive, whitespace trimmed) + * as confirmation; anything else aborts. + * - If not running interactively (agent/script/pipe — e.g. stdin is piped even if + * stdout is a TTY), prints the message and exits with an error instructing the + * caller to pass --yes. * * @param message A description of what will happen and why it is risky. * @param yes The value of the --yes flag from the command options. @@ -191,7 +194,7 @@ export async function confirmOrExit(message: string, yes: boolean): Promise((resolve) => { rl.question('Proceed? [y/N] ', (answer) => { rl.close(); - if (answer.toLowerCase() !== 'y') { + const normalized = answer.trim().toLowerCase(); + if (normalized !== 'y' && normalized !== 'yes') { console.log('Aborted.'); process.exit(0); } From 8226c876695abf0a60778bcdc6a0a3d28eeb55fb Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:46:18 -0600 Subject: [PATCH 6/9] Add test coverage for confirmOrExit and kickstart:kill - utils.ts: extract isConfirmationAccepted() as a pure, exported function so the accept/reject decision logic can be unit tested directly without simulating a real TTY - kickstart-kill.ts: export action() and add an injectable deps parameter (isDockerInstalled, confirmOrExit, spawn) so tests can exercise the confirmation gating without touching real docker or exiting the process - add __tests__/utils.test.js covering isConfirmationAccepted and the yes-bypass / non-interactive TTY-detection paths of confirmOrExit - add __tests__/commands/kickstart-kill.test.js covering docker-not-installed, CLI_DIR mismatch, --yes bypass, and confirm-rejected gating paths - wire both new test files into the test and test:unit npm scripts --- __tests__/commands/kickstart-kill.test.js | 118 ++++++++++++++++++++++ __tests__/utils.test.js | 105 +++++++++++++++++++ package.json | 4 +- src/commands/kickstart-kill.ts | 20 +++- src/utils.ts | 9 +- 5 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 __tests__/commands/kickstart-kill.test.js create mode 100644 __tests__/utils.test.js diff --git a/__tests__/commands/kickstart-kill.test.js b/__tests__/commands/kickstart-kill.test.js new file mode 100644 index 0000000..1eff41f --- /dev/null +++ b/__tests__/commands/kickstart-kill.test.js @@ -0,0 +1,118 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { action } from "../../src/commands/kickstart-kill.js" + +/** + * Fake child-process-like object returned by mocked spawn — supports the + * minimal surface kickstart-kill's action() touches (.on, .stdout) without + * running any real process. + */ +function fakeChildProcess() { + return { + on: () => {}, + stdout: undefined, + } +} + +describe('kickstart:kill action()', () => { + test('does not call confirmOrExit or spawn when Docker is not installed', async () => { + const confirmCalls = [] + const spawnCalls = [] + + await action( + { yes: false }, + { + isDockerInstalled: () => false, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + + assert.equal(confirmCalls.length, 0, 'confirmOrExit should not be called') + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) + + test('does not call confirmOrExit or spawn when CLI_DIR does not match cwd', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = '/not/the/current/directory' + + const confirmCalls = [] + const spawnCalls = [] + + try { + await action( + { yes: false }, + { + isDockerInstalled: () => true, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(confirmCalls.length, 0, 'confirmOrExit should not be called') + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) + + test('yes=true calls confirmOrExit (which resolves immediately) then spawn', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = process.cwd() + + const confirmCalls = [] + const spawnCalls = [] + + try { + await action( + { yes: true }, + { + isDockerInstalled: () => true, + confirmOrExit: async (...args) => { confirmCalls.push(args) }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(confirmCalls.length, 1, 'confirmOrExit should be called once') + assert.equal(confirmCalls[0][1], true, 'confirmOrExit should receive yes=true') + assert.equal(spawnCalls.length, 1, 'spawn should be called once') + assert.equal(spawnCalls[0][0], 'docker compose down -v') + }) + + test('when confirmOrExit rejects (declined/non-interactive), spawn is never called', async () => { + const originalCliDir = process.env.CLI_DIR + process.env.CLI_DIR = process.cwd() + + const spawnCalls = [] + + try { + await action( + { yes: false }, + { + isDockerInstalled: () => true, + confirmOrExit: async () => { throw new Error('declined') }, + spawn: (...args) => { spawnCalls.push(args); return fakeChildProcess() }, + } + ) + } finally { + if (originalCliDir === undefined) { + delete process.env.CLI_DIR + } else { + process.env.CLI_DIR = originalCliDir + } + } + + assert.equal(spawnCalls.length, 0, 'spawn should not be called') + }) +}) diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js new file mode 100644 index 0000000..2dd34fe --- /dev/null +++ b/__tests__/utils.test.js @@ -0,0 +1,105 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { isConfirmationAccepted, confirmOrExit } from "../src/utils.js" + +describe('isConfirmationAccepted()', () => { + test('accepts "y"', () => { + assert.equal(isConfirmationAccepted('y'), true) + }) + + test('accepts "yes"', () => { + assert.equal(isConfirmationAccepted('yes'), true) + }) + + test('accepts case-insensitive variants', () => { + assert.equal(isConfirmationAccepted('Y'), true) + assert.equal(isConfirmationAccepted('YES'), true) + assert.equal(isConfirmationAccepted('Yes'), true) + }) + + test('accepts whitespace-padded variants', () => { + assert.equal(isConfirmationAccepted(' y '), true) + assert.equal(isConfirmationAccepted(' yes '), true) + }) + + test('rejects "n"', () => { + assert.equal(isConfirmationAccepted('n'), false) + }) + + test('rejects empty string', () => { + assert.equal(isConfirmationAccepted(''), false) + }) + + test('rejects unrelated text', () => { + assert.equal(isConfirmationAccepted('nope'), false) + assert.equal(isConfirmationAccepted('ye'), false) + assert.equal(isConfirmationAccepted('sure'), false) + }) +}) + +describe('confirmOrExit()', () => { + test('yes=true resolves immediately without touching stdin/stdout', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + await confirmOrExit('This is risky', true) + + assert.equal(exitMock.mock.calls.length, 0, 'process.exit should not be called') + }) + + test('non-interactive (stdin not a TTY) exits with code 1', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = false + process.stdout.isTTY = true + + try { + await confirmOrExit('This is risky', false) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('non-interactive (stdout not a TTY) exits with code 1', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = true + process.stdout.isTTY = false + + try { + await confirmOrExit('This is risky', false) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) + + test('non-interactive (both not TTYs) exits with code 1', async (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + const originalStdinTTY = process.stdin.isTTY + const originalStdoutTTY = process.stdout.isTTY + + process.stdin.isTTY = false + process.stdout.isTTY = false + + try { + await confirmOrExit('This is risky', false) + } finally { + process.stdin.isTTY = originalStdinTTY + process.stdout.isTTY = originalStdoutTTY + } + + assert.equal(exitMock.mock.calls.length, 1, 'process.exit should be called once') + assert.equal(exitMock.mock.calls[0].arguments[0], 1) + }) +}) diff --git a/package.json b/package.json index 0759111..cabb068 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", - "test": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/integration/apply/apply.integration.test.js", + "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/integration/apply/apply.integration.test.js", "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js" + "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js" }, "keywords": [ "fusionauth", diff --git a/src/commands/kickstart-kill.ts b/src/commands/kickstart-kill.ts index 0e33fca..4474ba6 100644 --- a/src/commands/kickstart-kill.ts +++ b/src/commands/kickstart-kill.ts @@ -5,24 +5,34 @@ import { spawn } from 'node:child_process'; import { betaWarning, confirmOrExit, isDockerInstalled, logEvent } from "../utils.js"; import boxen from "boxen"; +// Dependencies below are injectable for testing — avoids real docker/confirm/exit calls +export interface KillDeps { + isDockerInstalled?: typeof isDockerInstalled; + confirmOrExit?: typeof confirmOrExit; + spawn?: typeof spawn; +} + +export const action = async function ({ yes }: { yes: boolean }, deps: KillDeps = {}) { + const checkDocker = deps.isDockerInstalled ?? isDockerInstalled; + const confirm = deps.confirmOrExit ?? confirmOrExit; + const spawnFn = deps.spawn ?? spawn; -const action = async function ({ yes }: { yes: boolean }) { betaWarning(); try { - if (!isDockerInstalled()) throw (chalk.red('Error: You need Docker to run.')) + if (!checkDocker()) throw (chalk.red('Error: You need Docker to run.')) if (process.cwd() != process.env.CLI_DIR) throw(chalk.red('Error: Current directory was not kickstarted.')) logEvent('cli command kickstart:kill') - await confirmOrExit( + await confirm( "This will run 'docker compose down -v', destroying the container and all database data. This cannot be undone.", yes ); console.log(chalk.yellow('Killing FusionAuth...\n')) try { - const starting = spawn('docker compose down -v', { shell: true, stdio: 'inherit' }) + const starting = spawnFn('docker compose down -v', { shell: true, stdio: 'inherit' }) starting.on('error', e => { console.error(e) }) @@ -50,4 +60,4 @@ export const kickstartKill = new Command() .command('kickstart:kill') .description('Runs docker compose down in current directory') .option('--yes', 'Skip confirmation prompt', false) - .action(action) + .action((options) => action(options)) diff --git a/src/utils.ts b/src/utils.ts index 517ed5d..ff8dd18 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -175,6 +175,12 @@ export function errorAndExit(message: string, error?: any) { process.exit(1); } +// Exported for testability — pure logic, no I/O, easy to unit test directly. +export function isConfirmationAccepted(answer: string): boolean { + const normalized = answer.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; +} + /** * Prompts the user for confirmation before proceeding with a risky operation. * @@ -205,8 +211,7 @@ export async function confirmOrExit(message: string, yes: boolean): Promise((resolve) => { rl.question('Proceed? [y/N] ', (answer) => { rl.close(); - const normalized = answer.trim().toLowerCase(); - if (normalized !== 'y' && normalized !== 'yes') { + if (!isConfirmationAccepted(answer)) { console.log('Aborted.'); process.exit(0); } From c6ac9751ec5fd2e9aa02d8e4f620eba37f8a5ecd Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:58:18 -0600 Subject: [PATCH 7/9] Fix confirmOrExit silently proceeding when process.exit is mocked/deferred Previously, the rl.question callback called process.exit(0) on decline but had no return statement, so resolve() ran unconditionally afterward. In production this was masked because process.exit halts execution synchronously, but in any environment where exit is mocked or deferred (e.g. tests), a declined confirmation would be silently treated as accepted, letting the caller proceed with the risky operation. - extract handleConfirmationAnswer(answer, resolve, reject): resolves on accept, exits + rejects on decline, so the promise can never silently resolve when exit doesn't actually happen - confirmOrExit now passes both resolve and reject into handleConfirmationAnswer - add 3 tests in __tests__/utils.test.js covering accept, decline, and the decline-with-mocked-exit case that reproduces the original bug --- __tests__/utils.test.js | 36 +++++++++++++++++++++++++++++++++++- src/utils.ts | 26 ++++++++++++++++++++------ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 2dd34fe..53df9fa 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -1,6 +1,6 @@ import { describe, test } from "node:test" import assert from "node:assert/strict" -import { isConfirmationAccepted, confirmOrExit } from "../src/utils.js" +import { isConfirmationAccepted, handleConfirmationAnswer, confirmOrExit } from "../src/utils.js" describe('isConfirmationAccepted()', () => { test('accepts "y"', () => { @@ -37,6 +37,40 @@ describe('isConfirmationAccepted()', () => { }) }) +describe('handleConfirmationAnswer()', () => { + test('accepted answer calls resolve, not reject or process.exit', (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + let resolved = false + let rejected = false + + handleConfirmationAnswer('y', () => { resolved = true }, () => { rejected = true }) + + assert.equal(resolved, true) + assert.equal(rejected, false) + assert.equal(exitMock.mock.calls.length, 0) + }) + + test('declined answer calls process.exit(0)', (t) => { + const exitMock = t.mock.method(process, 'exit', () => {}) + + handleConfirmationAnswer('n', () => {}, () => {}) + + assert.equal(exitMock.mock.calls.length, 1) + assert.equal(exitMock.mock.calls[0].arguments[0], 0) + }) + + test('declined answer rejects rather than resolving when process.exit is mocked (does not actually exit)', (t) => { + t.mock.method(process, 'exit', () => {}) + let resolved = false + let rejectedWith + + handleConfirmationAnswer('n', () => { resolved = true }, (err) => { rejectedWith = err }) + + assert.equal(resolved, false, 'resolve should never be called on decline') + assert.ok(rejectedWith instanceof Error, 'reject should be called with an Error') + }) +}) + describe('confirmOrExit()', () => { test('yes=true resolves immediately without touching stdin/stdout', async (t) => { const exitMock = t.mock.method(process, 'exit', () => {}) diff --git a/src/utils.ts b/src/utils.ts index ff8dd18..c03deff 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -181,6 +181,24 @@ export function isConfirmationAccepted(answer: string): boolean { return normalized === 'y' || normalized === 'yes'; } +// Exported for testability — settles the prompt's Promise without needing a real TTY/readline round-trip. +export function handleConfirmationAnswer( + answer: string, + resolve: () => void, + reject: (reason?: any) => void +): void { + if (isConfirmationAccepted(answer)) { + resolve(); + return; + } + console.log('Aborted.'); + process.exit(0); + // Only reached if process.exit was mocked/deferred (e.g. in tests) — reject rather + // than falling through to resolve(), which would incorrectly treat a decline as + // confirmation. + reject(new Error('Aborted by user.')); +} + /** * Prompts the user for confirmation before proceeding with a risky operation. * @@ -208,14 +226,10 @@ export async function confirmOrExit(message: string, yes: boolean): Promise((resolve) => { + await new Promise((resolve, reject) => { rl.question('Proceed? [y/N] ', (answer) => { rl.close(); - if (!isConfirmationAccepted(answer)) { - console.log('Aborted.'); - process.exit(0); - } - resolve(); + handleConfirmationAnswer(answer, resolve, reject); }); }); } From e1597280a58ecd6c77608bee60655a64233a1622 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:08:50 -0600 Subject: [PATCH 8/9] Add test coverage for import:generate deprecated-flag detection - extract getDeprecatedFlagUsage(argv) as a pure, exported function so the deprecation-detection logic is testable without mocking process.argv or console.warn - export DEPRECATED_FLAGS for use in tests - add __tests__/commands/import-generate.test.js covering: no deprecated flags used, bare --flag and --flag=value forms detected, multiple deprecated flags detected together, new kebab-case form not flagged, and that both the deprecated and current flag spellings populate the same underlying Commander option property - wire the new test file into the test and test:unit npm scripts --- __tests__/commands/import-generate.test.js | 64 ++++++++++++++++++++++ package.json | 4 +- src/commands/import-generate.ts | 28 ++++++---- 3 files changed, 84 insertions(+), 12 deletions(-) create mode 100644 __tests__/commands/import-generate.test.js diff --git a/__tests__/commands/import-generate.test.js b/__tests__/commands/import-generate.test.js new file mode 100644 index 0000000..c64fc1f --- /dev/null +++ b/__tests__/commands/import-generate.test.js @@ -0,0 +1,64 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { getDeprecatedFlagUsage, importGenerate } from "../../src/commands/import-generate.js" + +describe('getDeprecatedFlagUsage()', () => { + test('returns empty array when no deprecated flags are used', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--number-of-files', '5']) + assert.deepEqual(usage, []) + }) + + test('detects a bare deprecated flag (--flag value form)', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles', '5']) + assert.equal(usage.length, 1) + assert.deepEqual(usage[0], ['--numberOfFiles', '--number-of-files']) + }) + + test('detects a deprecated flag in --flag=value form', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles=5']) + assert.equal(usage.length, 1) + assert.deepEqual(usage[0], ['--numberOfFiles', '--number-of-files']) + }) + + test('detects multiple deprecated flags used together', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--numberOfFiles', '5', '--groupId=abc']) + const oldFlags = usage.map(([old]) => old) + assert.ok(oldFlags.includes('--numberOfFiles')) + assert.ok(oldFlags.includes('--groupId')) + assert.equal(usage.length, 2) + }) + + test('does not flag the new kebab-case form as deprecated', () => { + const usage = getDeprecatedFlagUsage(['node', 'script', '--group-id', 'abc']) + assert.deepEqual(usage, []) + }) +}) + +describe('import:generate option parsing', () => { + test('deprecated --numberOfFiles populates the same option as --number-of-files', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--numberOfFiles', '5'], { from: 'user' }) + + assert.equal(capturedOptions.numberOfFiles, '5') + }) + + test('--number-of-files populates the same numberOfFiles property', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--number-of-files', '7'], { from: 'user' }) + + assert.equal(capturedOptions.numberOfFiles, '7') + }) + + test('deprecated --groupId populates the same option as --group-id', async () => { + let capturedOptions + importGenerate.action((options) => { capturedOptions = options }) + + await importGenerate.parseAsync(['--groupId', 'abc-123'], { from: 'user' }) + + assert.equal(capturedOptions.groupId, 'abc-123') + }) +}) diff --git a/package.json b/package.json index cabb068..e058060 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "prepublishOnly": "npm run build", "postinstall": "test -f dist/postinstall.js && node dist/postinstall.js || true", "start": "node --import=tsx src/index.ts", - "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/integration/apply/apply.integration.test.js", + "test": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js __tests__/integration/apply/apply.integration.test.js", "test:integration": "NODE_ENV=test node --import=tsx --test __tests__/integration/apply/apply.integration.test.js", - "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js" + "test:unit": "NODE_ENV=test node --import=tsx --test __tests__/utils.test.js __tests__/postInstall/postinstall.test.js __tests__/telemetry/telemetry.test.js __tests__/utilities/kickstart/validator.test.js __tests__/utilities/kickstart/variable-substitution.test.js __tests__/commands/kickstart-install.test.js __tests__/commands/kickstart-kill.test.js __tests__/commands/import-generate.test.js" }, "keywords": [ "fusionauth", diff --git a/src/commands/import-generate.ts b/src/commands/import-generate.ts index cc385ea..ccd11cd 100644 --- a/src/commands/import-generate.ts +++ b/src/commands/import-generate.ts @@ -7,7 +7,7 @@ import {errorAndExit, logEvent} from '../utils.js'; import { faker } from '@faker-js/faker'; import * as fs from 'fs'; -const DEPRECATED_FLAGS: Record = { +export const DEPRECATED_FLAGS: Record = { '--numberOfFiles': '--number-of-files', '--countPerFile': '--count-per-file', '--applicationId': '--application-id', @@ -16,6 +16,22 @@ const DEPRECATED_FLAGS: Record = { '--filePrefix': '--file-prefix', }; +// Exported for testability — pure function, no I/O; returns [oldFlag, replacement] pairs found in argv. +export function getDeprecatedFlagUsage(argv: string[]): Array<[string, string]> { + return Object.entries(DEPRECATED_FLAGS).filter(([old]) => + argv.some((arg) => arg === old || arg.startsWith(`${old}=`)) + ); +} + +function warnDeprecatedFlags(argv: string[] = process.argv): void { + for (const [old, replacement] of getDeprecatedFlagUsage(argv)) { + console.warn(chalk.yellow( + `DEPRECATION WARNING: please use ${replacement} going forward. ` + + `${old} will be deprecated in a future release.` + )); + } +} + const action = async function ({numberOfFiles, countPerFile, applicationId, groupId, tmpDir, filePrefix} : { numberOfFiles?: string | undefined; @@ -26,15 +42,7 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou filePrefix?: string | undefined; } ): Promise { - for (const [old, replacement] of Object.entries(DEPRECATED_FLAGS)) { - const wasUsed = process.argv.some((arg) => arg === old || arg.startsWith(`${old}=`)); - if (wasUsed) { - console.warn(chalk.yellow( - `DEPRECATION WARNING: please use ${replacement} going forward. ` + - `${old} will be deprecated in a future release.` - )); - } - } + warnDeprecatedFlags(); logEvent('cli command import:generate') From 4c3ccc759e000bc0995c2d86cb29f2e8a10d2a24 Mon Sep 17 00:00:00 2001 From: Andy Pai <8798244+andrewpai@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:22:54 -0600 Subject: [PATCH 9/9] Fix crypto.randomUUID global usage and confirmOrExit non-interactive gap - kickstart-install.ts: import randomUUID from node:crypto explicitly instead of relying on the global WebCrypto object, matching the convention already used elsewhere in the codebase - utils.ts: confirmOrExit() now throws after errorAndExit() in the non-interactive path, mirroring the fix already applied to handleConfirmationAnswer in the interactive path. In production this is a no-op since process.exit(1) halts synchronously first, but in any environment where exit is mocked/deferred, the promise now rejects instead of silently resolving and letting the caller proceed with the risky operation - update the three non-interactive tests in __tests__/utils.test.js to assert.rejects, which now actually exercises the fixed behavior --- __tests__/utils.test.js | 12 ++++++------ src/commands/kickstart-install.ts | 5 +++-- src/utils.ts | 4 +++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 53df9fa..2f2b097 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -80,7 +80,7 @@ describe('confirmOrExit()', () => { assert.equal(exitMock.mock.calls.length, 0, 'process.exit should not be called') }) - test('non-interactive (stdin not a TTY) exits with code 1', async (t) => { + test('non-interactive (stdin not a TTY) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { const exitMock = t.mock.method(process, 'exit', () => {}) const originalStdinTTY = process.stdin.isTTY const originalStdoutTTY = process.stdout.isTTY @@ -89,7 +89,7 @@ describe('confirmOrExit()', () => { process.stdout.isTTY = true try { - await confirmOrExit('This is risky', false) + await assert.rejects(() => confirmOrExit('This is risky', false)) } finally { process.stdin.isTTY = originalStdinTTY process.stdout.isTTY = originalStdoutTTY @@ -99,7 +99,7 @@ describe('confirmOrExit()', () => { assert.equal(exitMock.mock.calls[0].arguments[0], 1) }) - test('non-interactive (stdout not a TTY) exits with code 1', async (t) => { + test('non-interactive (stdout not a TTY) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { const exitMock = t.mock.method(process, 'exit', () => {}) const originalStdinTTY = process.stdin.isTTY const originalStdoutTTY = process.stdout.isTTY @@ -108,7 +108,7 @@ describe('confirmOrExit()', () => { process.stdout.isTTY = false try { - await confirmOrExit('This is risky', false) + await assert.rejects(() => confirmOrExit('This is risky', false)) } finally { process.stdin.isTTY = originalStdinTTY process.stdout.isTTY = originalStdoutTTY @@ -118,7 +118,7 @@ describe('confirmOrExit()', () => { assert.equal(exitMock.mock.calls[0].arguments[0], 1) }) - test('non-interactive (both not TTYs) exits with code 1', async (t) => { + test('non-interactive (both not TTYs) exits with code 1 and rejects (does not let caller proceed) when exit is mocked', async (t) => { const exitMock = t.mock.method(process, 'exit', () => {}) const originalStdinTTY = process.stdin.isTTY const originalStdoutTTY = process.stdout.isTTY @@ -127,7 +127,7 @@ describe('confirmOrExit()', () => { process.stdout.isTTY = false try { - await confirmOrExit('This is risky', false) + await assert.rejects(() => confirmOrExit('This is risky', false)) } finally { process.stdin.isTTY = originalStdinTTY process.stdout.isTTY = originalStdoutTTY diff --git a/src/commands/kickstart-install.ts b/src/commands/kickstart-install.ts index c88ca24..4181cc5 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -8,6 +8,7 @@ import fs from 'node:fs' import path from "node:path"; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { randomUUID } from 'node:crypto'; import { betaWarning, errorAndExit, isDirEmpty, isDockerInstalled, logEvent } from "../utils.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -216,8 +217,8 @@ const action = async function (dir: string, options: InstallOptions) { if (!fs.existsSync(directory)) throw (chalk.red(`Something went wrong. ${directory} does not exists.`)) await createKickstart(__dirname + '/resources/kickstart/kickstart.json', answers, directory) - const postgresPass = crypto.randomUUID() - const dbPass = crypto.randomUUID() + const postgresPass = randomUUID() + const dbPass = randomUUID() console.log(chalk.green(`Transferring environment variables`)) fs.renameSync(`${directory}/.env.defaults`, `${directory}/.env`) diff --git a/src/utils.ts b/src/utils.ts index c03deff..c2fa85f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -220,7 +220,9 @@ export async function confirmOrExit(message: string, yes: boolean): Promise