Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 23 additions & 36 deletions src/commands/kickstart-kill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
40 changes: 38 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<void> {
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<void>((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
*/
Expand Down Expand Up @@ -324,4 +360,4 @@ async function updateGlobalConfig(propertiesToAdd: PropertyToAdd | PropertyToAdd
}

fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2))
}
}