diff --git a/packages/cli/README.md b/packages/cli/README.md index 565d67f71..7a1c841b9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,10 +11,11 @@ from your terminal—without cloning or building the Pascal repository. npx @pascal-app/cli editor ``` -`npx` makes the CLI available for that invocation only; it does not add a permanent -`pascal` command to your shell. Continue to prefix commands with -`npx @pascal-app/cli`, or use the optional global installation below when you want the -shorter command. +On an interactive first run through `npx`, Pascal installs the same CLI version globally +after the editor becomes healthy. The shorter `pascal` command is therefore available +for `status`, `logs`, `stop`, and future sessions without another setup step. If the +global installation is unavailable because of local npm permissions, the editor remains +running and the CLI shows the equivalent `npx` commands plus the manual install command. The first run walks through local storage, runtime installation, automatic port selection, process startup, and a health check with live terminal feedback. It then @@ -54,16 +55,16 @@ pnpm dlx @pascal-app/cli editor bunx @pascal-app/cli editor ``` -Or install the `pascal` command globally: +To install the `pascal` command before starting the editor: ```bash npm install --global @pascal-app/cli pascal editor ``` -After a global installation, `pascal status`, `pascal logs --follow`, and the other -commands work directly in new terminal sessions. A prior `npx` invocation alone does -not install this shortcut. +After the interactive `npx` first run or a global installation, `pascal status`, +`pascal logs --follow`, and the other commands work directly in the current terminal +and future sessions. Use `--no-open` on a headless machine. Use `--foreground` when a process supervisor should own the editor or when you want logs attached to the current terminal. diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index dc813b07b..3062c1185 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -73,10 +73,10 @@ try { smokeEnvironment, ) if ( - !humanStart.stdout.includes('npx @pascal-app/cli status') || - !humanStart.stdout.includes('npm install --global @pascal-app/cli') + !humanStart.stdout.includes('pascal status') || + humanStart.stdout.includes('npm install --global @pascal-app/cli') ) { - throw new Error('human start output did not explain transient and global commands') + throw new Error('direct CLI start output did not use the persistent pascal command') } await run( process.execPath, diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index 0910238d8..a81be7ede 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process' import { parseArgs } from 'node:util' import { openBrowser } from '../browser.js' +import { installGlobalPascalCommand, isNpxInvocation } from '../command-install.js' import { collectInfo, runDoctor } from '../diagnostics.js' import { activateEditorRuntime, @@ -22,7 +23,11 @@ import { version } from '../version.js' const HELP = `Pascal — local 3D editor -RUN WITHOUT INSTALLING: +FIRST RUN: + npx @pascal-app/cli editor + Starts the editor and installs the shorter "pascal" command interactively. + +RUN A COMMAND THROUGH NPX: npx @pascal-app/cli ENABLE THE SHORT GLOBAL COMMAND: @@ -115,6 +120,22 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { } progress?.stop() if (values.open && !values['no-open']) openBrowser(result.state.url) + const npxInvocation = isNpxInvocation() + let commandInstalled = false + if (npxInvocation && !values.json && process.stdin.isTTY && process.stderr.isTTY) { + progress?.start('Installing the pascal command') + commandInstalled = await installGlobalPascalCommand(version) + if (commandInstalled) { + progress?.succeed('pascal command installed') + } else { + progress?.stop() + process.stderr.write( + '! The editor is ready, but npm could not install the pascal command globally.\n', + ) + } + } + const useShortCommand = !npxInvocation || commandInstalled + const commandPrefix = useShortCommand ? 'pascal' : 'npx @pascal-app/cli' output( values.json, { ...result.state, alreadyRunning: result.alreadyRunning }, @@ -124,13 +145,17 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { : `Pascal is ready at ${result.state.url}`, `Projects stay in ${paths.data}`, '', - 'Manage it with npx:', - ' npx @pascal-app/cli status Check the local editor', - ' npx @pascal-app/cli logs --follow Follow editor logs', - ' npx @pascal-app/cli stop Stop the background process', - '', - 'To enable the shorter "pascal" command in your shell:', - ' npm install --global @pascal-app/cli', + `Manage it with ${useShortCommand ? 'pascal' : 'npx'}:`, + ` ${commandPrefix} status Check the local editor`, + ` ${commandPrefix} logs --follow Follow editor logs`, + ` ${commandPrefix} stop Stop the background process`, + ...(useShortCommand + ? [] + : [ + '', + 'To install the shorter "pascal" command:', + ' npm install --global @pascal-app/cli', + ]), ].join('\n'), ) if (result.child) { diff --git a/packages/cli/src/command-install.test.ts b/packages/cli/src/command-install.test.ts new file mode 100644 index 000000000..ede6ae3df --- /dev/null +++ b/packages/cli/src/command-install.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' +import { installGlobalPascalCommand, isNpxInvocation } from './command-install.js' + +describe('short command installation', () => { + test('recognizes npm exec package-runner invocations', () => { + expect(isNpxInvocation({ npm_lifecycle_event: 'npx' })).toBe(true) + expect( + isNpxInvocation({ npm_command: 'exec', PATH: '/tmp/_npx/example/node_modules/.bin' }), + ).toBe(true) + expect(isNpxInvocation({ PATH: '/usr/local/bin:/usr/bin' })).toBe(false) + }) + + test('installs the exact running version without lifecycle scripts', async () => { + let invocation: { command: string; args: string[] } | undefined + const installed = await installGlobalPascalCommand('1.2.3', async (command, args) => { + invocation = { command, args } + return 0 + }) + + expect(installed).toBe(true) + expect(invocation).toEqual({ + command: process.platform === 'win32' ? 'npm.cmd' : 'npm', + args: ['install', '--global', '--ignore-scripts', '@pascal-app/cli@1.2.3'], + }) + }) + + test('reports an installer failure without throwing', async () => { + expect(await installGlobalPascalCommand('1.2.3', async () => 1)).toBe(false) + }) +}) diff --git a/packages/cli/src/command-install.ts b/packages/cli/src/command-install.ts new file mode 100644 index 000000000..24d90e256 --- /dev/null +++ b/packages/cli/src/command-install.ts @@ -0,0 +1,46 @@ +import { spawn } from 'node:child_process' + +const INSTALL_TIMEOUT_MS = 2 * 60_000 + +export function isNpxInvocation(environment: NodeJS.ProcessEnv = process.env): boolean { + return ( + environment.npm_lifecycle_event === 'npx' || + (environment.npm_command === 'exec' && + (environment.PATH ?? '').split(':').some((entry) => entry.includes('/_npx/'))) + ) +} + +export async function installGlobalPascalCommand( + packageVersion: string, + runInstaller: Installer = runNpmInstaller, +): Promise { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' + return ( + (await runInstaller(npm, [ + 'install', + '--global', + '--ignore-scripts', + `@pascal-app/cli@${packageVersion}`, + ])) === 0 + ) +} + +export type Installer = (command: string, args: string[]) => Promise + +async function runNpmInstaller(command: string, args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { stdio: 'ignore' }) + const timeout = setTimeout(() => { + child.kill('SIGTERM') + resolve(1) + }, INSTALL_TIMEOUT_MS) + child.once('error', () => { + clearTimeout(timeout) + resolve(1) + }) + child.once('exit', (code) => { + clearTimeout(timeout) + resolve(code ?? 1) + }) + }) +}