diff --git a/bin/welcome.mjs b/bin/welcome.mjs index f396faf..6f33f99 100755 --- a/bin/welcome.mjs +++ b/bin/welcome.mjs @@ -1,8 +1,18 @@ #!/usr/bin/env node -import { register } from 'tsx/esm/api' -// Register tsx loader so we can import .tsx files directly -register() +// Pre-bootstrap Node version gate: fail fast with a clear message before +// loading tsx/Ink, which would otherwise crash cryptically on an old Node. +const MIN_NODE_MAJOR = 18 +const major = Number(process.versions.node.split('.')[0]) +if (Number.isNaN(major) || major < MIN_NODE_MAJOR) { + console.error( + `\nFactorial welcome needs Node.js >= ${MIN_NODE_MAJOR} (you have ${process.versions.node}).` + ) + console.error('Install a newer Node (https://nodejs.org or `brew install node`) and re-run.\n') + process.exit(1) +} -// Now import and run the app +// Dynamic imports run after the gate (static imports would hoist above it). +const { register } = await import('tsx/esm/api') +register() await import('../src/index.tsx') diff --git a/package.json b/package.json index 1730c56..cc2d921 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "bin": { "factorial-welcome": "./bin/welcome.mjs" }, + "engines": { + "node": ">=18" + }, "scripts": { "start": "tsx src/index.tsx", "build": "tsc", diff --git a/src/commands/preflight.ts b/src/commands/preflight.ts index 94540a0..bbaa43f 100644 --- a/src/commands/preflight.ts +++ b/src/commands/preflight.ts @@ -10,12 +10,24 @@ export type PreflightResult = { message: string } +const MIN_NODE_MAJOR = 18 + /** Detect the user's shell */ async function checkShell(): Promise { const shell = getUserShell() return { name: 'Shell', status: 'ok', message: shell } } +/** Check Node.js version (warn if < 18). Covers the local `npm start` path. */ +async function checkNodeVersion(): Promise { + const version = process.versions.node + const major = Number(version.split('.')[0]) + if (Number.isNaN(major) || major < MIN_NODE_MAJOR) { + return { name: 'Node.js', status: 'warn', message: `${version} (< ${MIN_NODE_MAJOR} required)` } + } + return { name: 'Node.js', status: 'ok', message: version } +} + /** Check macOS version (warn if < 13 Ventura). Skip on Linux. */ async function checkOSVersion(): Promise { if (!isDarwin()) { @@ -114,7 +126,14 @@ async function checkNetwork(): Promise { export async function runPreflightChecks( onResult: (result: PreflightResult, index: number) => void ): Promise { - const checks = [checkShell, checkOSVersion, checkAdmin, checkDiskSpace, checkNetwork] + const checks = [ + checkShell, + checkNodeVersion, + checkOSVersion, + checkAdmin, + checkDiskSpace, + checkNetwork, + ] const results: PreflightResult[] = [] for (let i = 0; i < checks.length; i++) { diff --git a/src/commands/steps/04-clone-repo.ts b/src/commands/steps/04-clone-repo.ts index 13918c4..ba576f7 100644 --- a/src/commands/steps/04-clone-repo.ts +++ b/src/commands/steps/04-clone-repo.ts @@ -36,12 +36,27 @@ export async function runStep4( 1, `Cloning factorialco/factorial into ${REPO_PATH}... (this may take a while, patience!)` ) - const result = await sh( - `git clone git@github.com:${ORG_NAME}/${REPO_NAME}.git "${REPO_PATH}"`, - { interactive: true } - ) - if (result.code !== 0) { - throw new Error('Failed to clone Factorial repository') + // Blobless partial clone + retry with backoff for flaky networks + const maxAttempts = 3 + let cloned = false + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const result = await sh( + `git clone --filter=blob:none git@github.com:${ORG_NAME}/${REPO_NAME}.git "${REPO_PATH}"`, + { interactive: true } + ) + if (result.code === 0) { + cloned = true + break + } + // Clean the partial clone so the retry doesn't hit "already exists" + await sh(`rm -rf "${REPO_PATH}"`) + if (attempt < maxAttempts) { + onProgress(1, `Clone failed (attempt ${attempt}/${maxAttempts}), retrying...`) + await new Promise((r) => setTimeout(r, attempt * 5000)) + } + } + if (!cloned) { + throw new Error(`Failed to clone Factorial repository after ${maxAttempts} attempts`) } } else if (await dirExists(path.join(REPO_PATH, '.git'))) { onProgress(1, 'Repository already cloned, pulling latest...') diff --git a/src/commands/steps/05-version-manager.ts b/src/commands/steps/05-version-manager.ts index 49f6ed2..186b427 100644 --- a/src/commands/steps/05-version-manager.ts +++ b/src/commands/steps/05-version-manager.ts @@ -40,6 +40,12 @@ export async function runStep5( onProgress(1, 'Setting up mise version manager...') await ensureLine(shellRc, `eval "$(mise activate ${shell})"`) + // Best-effort: disable python-build's GitHub attestation check (older mise lacks this key) + const attestations = await sh('mise settings set python.github_attestations false') + if (attestations.code !== 0) { + onProgress(1, '⚠ Could not set python.github_attestations (continuing)') + } + // 2-5. Install plugins for (let i = 0; i < plugins.length; i++) { onProgress(i + 1, `Installing plugin: ${plugins[i]}...`) diff --git a/src/commands/steps/14-agent-skills.ts b/src/commands/steps/14-agent-skills.ts index 407f699..e836be2 100644 --- a/src/commands/steps/14-agent-skills.ts +++ b/src/commands/steps/14-agent-skills.ts @@ -2,6 +2,15 @@ import { type SetupConfig } from '../../context/index.js' import { SKILL_REPOS } from '../constants.js' import { getErrorMessage, sh, type ProgressCallback, type TaskResult } from '../helpers.js' +// Hard backstop so a stuck `skills add` is killed and skipped, not hung +const SKILL_INSTALL_TIMEOUT_MS = 3 * 60 * 1000 + +// Force non-interactive git/SSH so a clone fails fast instead of waiting on a prompt +const NON_INTERACTIVE_GIT_ENV = { + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', +} + /** Step 14: Install agent skills */ export async function runStep14( config: SetupConfig, @@ -14,9 +23,12 @@ export async function runStep14( onProgress(i, `npx skills add ${repo}...`) const result = await sh(`npx --yes skills add "${repo}" -g -y`, { interactive: true, + timeout: SKILL_INSTALL_TIMEOUT_MS, + env: NON_INTERACTIVE_GIT_ENV, }) if (result.code !== 0) { - // Non-fatal: log but continue + // Non-fatal: warn and continue so one bad skill doesn't block setup + onProgress(i, `⚠ Skipped "${repo}" (skills add failed or timed out) — continuing.`) } }