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
18 changes: 14 additions & 4 deletions bin/welcome.mjs
Original file line number Diff line number Diff line change
@@ -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')
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"bin": {
"factorial-welcome": "./bin/welcome.mjs"
},
"engines": {
"node": ">=18"
},
"scripts": {
"start": "tsx src/index.tsx",
"build": "tsc",
Expand Down
21 changes: 20 additions & 1 deletion src/commands/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,24 @@ export type PreflightResult = {
message: string
}

const MIN_NODE_MAJOR = 18

/** Detect the user's shell */
async function checkShell(): Promise<PreflightResult> {
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<PreflightResult> {
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<PreflightResult> {
if (!isDarwin()) {
Expand Down Expand Up @@ -114,7 +126,14 @@ async function checkNetwork(): Promise<PreflightResult> {
export async function runPreflightChecks(
onResult: (result: PreflightResult, index: number) => void
): Promise<PreflightResult[]> {
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++) {
Expand Down
27 changes: 21 additions & 6 deletions src/commands/steps/04-clone-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...')
Expand Down
6 changes: 6 additions & 0 deletions src/commands/steps/05-version-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]}...`)
Expand Down
14 changes: 13 additions & 1 deletion src/commands/steps/14-agent-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.`)
}
}

Expand Down