diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 868a6fb..4e9f819 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [12.x, 14.x, 15.x] + node: [18.x, 20.x, 22.x] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/packages/create/README.md b/packages/create/README.md index c140b1a..2b8fc97 100644 --- a/packages/create/README.md +++ b/packages/create/README.md @@ -47,6 +47,7 @@ pnpx @marko/create ## Options +- `--name`: The name of the new app (also accepted as the first positional argument). - `--dir`: Provide a different directory to setup the project in (default to `pwd`). - `--template`: The name of an example from [marko-js/examples](https://github.com/marko-js/examples/tree/master/examples). - An example name @@ -64,6 +65,34 @@ pnpx @marko/create - ```bash marko-create --installer pnpm ``` +- `--yes` (`-y`): Skip the interactive prompts and accept the defaults. + +## Non-interactive usage (CI & AI agents) + +The interactive prompts require a human at a terminal. People running the +command in a normal terminal get the prompts exactly as before. When there is no +human to answer them, `@marko/create` skips the prompts and uses defaults +instead of hanging. Defaults are used when any of the following is true: + +- `--yes` (`-y`) is passed. +- A known AI coding agent is detected (e.g. `CLAUDECODE`, `CURSOR_TRACE_ID`, or a + generic `AGENT`/`AI_AGENT` environment variable). This applies even when the + agent attaches a pseudo-TTY, so the command never hangs waiting for input. +- The `CI` environment variable is set. + +In those cases the project name defaults to `my-app` (pass `--name`/the +positional argument to override) and the template defaults to the starter app +(pass `--template` to override). + +If `stdin` is not a TTY but none of the above apply (for example, piped input), +the command exits with a clear error asking you to pass `--name`/`--template` or +`--yes`, rather than silently guessing. The command also exits with a non-zero +status code if project creation fails, so it can be used reliably in scripts. + +```bash +# Fully non-interactive; safe to run in CI or from an agent. +npx @marko/create my-app --template basic --yes +``` # API diff --git a/packages/create/src/cli.js b/packages/create/src/cli.js index 77ca874..436fc58 100644 --- a/packages/create/src/cli.js +++ b/packages/create/src/cli.js @@ -33,6 +33,11 @@ exports.parse = function parse(argv) { description: "Override the package manager used to install dependencies. By default will determine from create command and fallback to npm." }, + "--yes -y": { + type: "boolean", + description: + "Skip interactive prompts and accept defaults. Automatically enabled in non-interactive environments (CI, AI agents, piped input)." + }, "--version -v": { type: "boolean", descrption: `print ${details.name} version` @@ -81,70 +86,111 @@ exports.parse = function parse(argv) { return options; }; +// Environment variables set by common AI coding agents/assistants. When one is +// present we treat the session as agent-driven even if a (pseudo) TTY is +// attached, since there is no human available to answer a prompt. The list is +// intentionally small and can grow; agents can also just pass `--yes`. +function isAgent() { + return Boolean( + process.env.CLAUDECODE || + process.env.CLAUDE_CODE || + process.env.CURSOR_TRACE_ID || + process.env.AI_AGENT || + process.env.AGENT + ); +} + exports.run = async function run(options = {}) { + const hasTTY = Boolean(process.stdin.isTTY); + // Accept defaults without prompting for agents, CI, and explicit `--yes`. + const useDefaults = Boolean(options.yes) || isAgent() || Boolean(process.env.CI); + // Only render enquirer prompts when a human can actually answer them. This + // keeps the interactive experience unchanged for people in a real terminal. + const canPrompt = hasTTY && !useDefaults; const spinner = ora("Starting...").start(); try { if (!options.name || !options.template) { - spinner.stop(); - const examples = !options.template && getExamples(); - const trimHints = choices => - choices.map(choice => { - const size = 4 + choice.name.length + 1 + choice.hint.length; - if (size > process.stdout.columns) { - return { - ...choice, - hint: - choice.hint.slice(0, -1 + process.stdout.columns - size) + "…" - }; - } - return choice; - }); - - if (!options.name) { - const nameInput = new Input({ - name: "name", - message: "Type your project name", - initial: "my-app" - }); - options.name = await nameInput.run(); - } - - if (!options.template) { - const templateSelect = new Select({ - name: "template", - message: "Choose a template", - hint: "Use ↑ and ↓. Return ⏎ to submit.", - choices: [ - { - name: "Default starter app" - }, - { - name: "Example from marko-js/examples" + if (useDefaults) { + if (!options.name) { + options.name = "my-app"; + spinner.info( + chalk.yellow( + `No project name provided; using "${options.name}". Pass --name to override.` + ) + ); + } + // Leaving `options.template` undefined causes createProject to use the + // default starter example, which is the right non-interactive default. + spinner.start(); + } else if (!canPrompt) { + // No TTY and no signal to auto-accept defaults: don't hang and don't + // silently guess. Fail with actionable guidance instead. + throw new Error( + "An interactive terminal is required to choose a project name and template.\n" + + "Pass --name and --template, or --yes to accept the defaults, when running non-interactively." + ); + } else { + spinner.stop(); + const examples = !options.template && getExamples(); + const trimHints = choices => + choices.map(choice => { + const size = 4 + choice.name.length + 1 + choice.hint.length; + if (size > process.stdout.columns) { + return { + ...choice, + hint: + choice.hint.slice(0, -1 + process.stdout.columns - size) + "…" + }; } - ] - }); + return choice; + }); - if ("Default starter app" !== (await templateSelect.run())) { - const choices = await examples; - const exampleSelect = new Select({ + if (!options.name) { + const nameInput = new Input({ + name: "name", + message: "Type your project name", + initial: "my-app" + }); + options.name = await nameInput.run(); + } + + if (!options.template) { + const templateSelect = new Select({ name: "template", - message: "Choose an example", - choices: trimHints(choices) + message: "Choose a template", + hint: "Use ↑ and ↓. Return ⏎ to submit.", + choices: [ + { + name: "Default starter app" + }, + { + name: "Example from marko-js/examples" + } + ] }); - const resizeListener = async () => { - const trimmed = trimHints(choices); - exampleSelect.choices.forEach((choice, i) => { - choice.hint = trimmed[i].hint; + + if ("Default starter app" !== (await templateSelect.run())) { + const choices = await examples; + const exampleSelect = new Select({ + name: "template", + message: "Choose an example", + choices: trimHints(choices) }); - exampleSelect.render(); - }; - process.stdout.on("resize", resizeListener); - options.template = await exampleSelect.run(); - process.stdout.off("resize", resizeListener); + const resizeListener = async () => { + const trimmed = trimHints(choices); + exampleSelect.choices.forEach((choice, i) => { + choice.hint = trimmed[i].hint; + }); + exampleSelect.render(); + }; + process.stdout.on("resize", resizeListener); + options.template = await exampleSelect.run(); + process.stdout.off("resize", resizeListener); + } } + spinner.start(); } - spinner.start(); } const result = createProject(options); @@ -168,6 +214,9 @@ exports.run = async function run(options = {}) { } catch (err) { spinner.fail(err.message + "\n"); console.error(err); + // Surface the failure to callers (CI, AI agents, scripts) via the exit code + // instead of exiting 0 on error. + process.exitCode = 1; } finally { clearTimeout(spinner.timeout); } diff --git a/packages/migrate/README.md b/packages/migrate/README.md index 4f0af2e..95e4ebe 100644 --- a/packages/migrate/README.md +++ b/packages/migrate/README.md @@ -38,6 +38,21 @@ npx @marko/migrate ./components/my-component.marko - `--dry-run`: Runs the migration in memory only. - `--safe`: Run all safe migrations ignoring any prompts. +## Non-interactive usage (CI & AI agents) + +Some migrations ask for a decision via an interactive prompt, which requires a +human at a terminal. People running in a normal terminal get the prompts as +before. When there is no human to answer them — no TTY on `stdin`, the `CI` +environment variable, or a detected AI coding agent (e.g. `CLAUDECODE`, +`CURSOR_TRACE_ID`, or a generic `AGENT`/`AI_AGENT` variable) — the command +applies the automatic migrations and, rather than hanging when a prompt is +needed, exits with a clear error telling you to re-run with `--safe`. Use +`--safe` to apply only the automatic migrations with no prompts: + +```bash +npx @marko/migrate ./src --safe +``` + # API ## Installation diff --git a/packages/migrate/src/cli.js b/packages/migrate/src/cli.js index f66aad4..86e4e45 100644 --- a/packages/migrate/src/cli.js +++ b/packages/migrate/src/cli.js @@ -82,7 +82,36 @@ export function parse(argv) { return options; } +// Environment variables set by common AI coding agents/assistants. When one is +// present we treat the session as agent-driven even if a (pseudo) TTY is +// attached, since there is no human available to answer a prompt. +function isAgent() { + return Boolean( + process.env.CLAUDECODE || + process.env.CLAUDE_CODE || + process.env.CURSOR_TRACE_ID || + process.env.AI_AGENT || + process.env.AGENT + ); +} + +// Migrations that require a decision use enquirer prompts, which need an +// interactive terminal. When there is no human to answer (no stdin TTY, CI, or +// an AI agent), fail with clear guidance the first time a prompt is needed +// instead of hanging forever. Automatic migrations still run; only interactive +// ones require `--safe` (to skip them) or a real terminal. +const nonInteractivePrompt = () => { + throw new Error( + "This migration needs an interactive prompt, but no interactive terminal is available.\n" + + "Re-run with `--safe` to apply only the automatic migrations, or run in an interactive terminal." + ); +}; + export async function run(options) { + // Computed at call time (not import time) so it reflects the current stdin/CI + // state, mirroring packages/create/src/cli.js. + const interactive = + Boolean(process.stdin.isTTY) && !process.env.CI && !isAgent(); await markoMigrate({ syntax: "html", maxLen: 80, @@ -91,7 +120,7 @@ export async function run(options) { ignore: ["/node_modules", ".*"], dir: process.cwd(), ...options, - prompt, + prompt: interactive || options.safe ? prompt : nonInteractivePrompt, onWriteFile(file, source) { return fs.writeFile(file, source, "utf-8"); },