Skip to content
Merged
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
17 changes: 9 additions & 8 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/scripts/smoke-packed-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 33 additions & 8 deletions packages/cli/src/bin/pascal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <command>

ENABLE THE SHORT GLOBAL COMMAND:
Expand Down Expand Up @@ -115,6 +120,22 @@ async function runStart(args: string[], shouldOpen: boolean): Promise<void> {
}
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 },
Expand All @@ -124,13 +145,17 @@ async function runStart(args: string[], shouldOpen: boolean): Promise<void> {
: `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',
]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong short command for transient runners

Medium Severity

useShortCommand treats every non-npx launch as if pascal is already installed. Documented transient runners like pnpm dlx and bunx never match isNpxInvocation and never run the global install, so the ready output still directs users to bare pascal commands that are not on PATH, and it omits the manual install hint.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9d373e4. Configure here.

].join('\n'),
)
if (result.child) {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/command-install.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
46 changes: 46 additions & 0 deletions packages/cli/src/command-install.ts
Original file line number Diff line number Diff line change
@@ -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/')))
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows npx detection uses Unix PATH

Low Severity

The npm exec branch of isNpxInvocation splits PATH on : and looks for /_npx/. On Windows, PATH is ;-separated and the npx cache uses backslashes, so real npx/npm exec runs can be missed. That skips global install and falls through to bare pascal guidance.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9d373e4. Configure here.

}

export async function installGlobalPascalCommand(
packageVersion: string,
runInstaller: Installer = runNpmInstaller,
): Promise<boolean> {
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<number>

async function runNpmInstaller(command: string, args: string[]): Promise<number> {
return new Promise<number>((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)
})
})
}
Loading