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..4534148 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing + +## Command Structure +Commands generally follow the form: + +fusionauth namespace:command [--command-option] ... + +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 + +## 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. + +## Testing + +### Running the tests + +```bash +# Unit tests (run these before every commit) +npm run test:unit + +# Integration tests (requires a live FusionAuth instance) +npm run test:integration + +# Full suite +npm run test +``` + +The integration tests manage a Docker container automatically. Several environment variables control their behaviour: + +| 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) | + +### Requirements + +- **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/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/__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__/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__/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/__tests__/utils.test.js b/__tests__/utils.test.js new file mode 100644 index 0000000..2f2b097 --- /dev/null +++ b/__tests__/utils.test.js @@ -0,0 +1,139 @@ +import { describe, test } from "node:test" +import assert from "node:assert/strict" +import { isConfirmationAccepted, handleConfirmationAnswer, 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('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', () => {}) + + 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 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 + + process.stdin.isTTY = false + process.stdout.isTTY = true + + try { + await assert.rejects(() => 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 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 + + process.stdin.isTTY = true + process.stdout.isTTY = false + + try { + await assert.rejects(() => 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 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 + + process.stdin.isTTY = false + process.stdout.isTTY = false + + try { + await assert.rejects(() => 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-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/package.json b/package.json index dab5caf..e058060 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__/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__/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__/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 ab79e76..ccd11cd 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,31 @@ import {errorAndExit, logEvent} from '../utils.js'; import { faker } from '@faker-js/faker'; import * as fs from 'fs'; +export const DEPRECATED_FLAGS: Record = { + '--numberOfFiles': '--number-of-files', + '--countPerFile': '--count-per-file', + '--applicationId': '--application-id', + '--groupId': '--group-id', + '--tmpDir': '--tmp-dir', + '--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; @@ -17,6 +42,8 @@ const action = async function ({numberOfFiles, countPerFile, applicationId, grou filePrefix?: string | undefined; } ): Promise { + warnDeprecatedFlags(); + logEvent('cli command import:generate') console.log(`Generating users`); @@ -54,12 +81,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..4181cc5 100644 --- a/src/commands/kickstart-install.ts +++ b/src/commands/kickstart-install.ts @@ -8,11 +8,151 @@ 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 { randomUUID } from 'node:crypto'; +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 +169,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 +197,46 @@ 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() + + // 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 = randomUUID() + const dbPass = 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) } - } 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)) diff --git a/src/commands/kickstart-kill.ts b/src/commands/kickstart-kill.ts index 11e4863..4474ba6 100644 --- a/src/commands/kickstart-kill.ts +++ b/src/commands/kickstart-kill.ts @@ -2,57 +2,53 @@ 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"; +// 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 () { 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') - inquirer.prompt([ - { - type: 'confirm', - name: 'confirmation', - message: 'This is a destructive action. Are you sure you want to kill this container?' + 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 = spawnFn('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 +59,5 @@ const action = async function () { export const kickstartKill = new Command() .command('kickstart:kill') .description('Runs docker compose down in current directory') - .action(action) + .option('--yes', 'Skip confirmation prompt', false) + .action((options) => action(options)) diff --git a/src/utils.ts b/src/utils.ts index ec2da2b..c2fa85f 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', @@ -175,6 +175,67 @@ 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'; +} + +// 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. + * + * - If `yes` is true, returns immediately (caller has pre-confirmed). + * - 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. + */ +export async function confirmOrExit(message: string, yes: boolean): Promise { + if (yes) return; + + console.warn(chalk.yellow(message)); + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + errorAndExit('Pass --yes to confirm this operation non-interactively.'); + // Only reached if process.exit was mocked/deferred (e.g. in tests) — throw rather + // than returning normally, which would incorrectly let the caller proceed. + throw new Error('Confirmation required: pass --yes to confirm this operation non-interactively.'); + } + + const { createInterface } = await import('node:readline'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + await new Promise((resolve, reject) => { + rl.question('Proceed? [y/N] ', (answer) => { + rl.close(); + handleConfirmationAnswer(answer, resolve, reject); + }); + }); +} + /** * Returns a console log that can be added to a beta feature to warn the user */ @@ -324,4 +385,4 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd } fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2)) -} \ No newline at end of file +}