diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04f0e0..553dee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,5 +46,8 @@ jobs: - name: Check package contents run: pnpm pack:codex-claw + - name: Smoke packed CLI + run: pnpm smoke:codex-claw:pack + - name: Audit dependencies run: pnpm audit --audit-level low diff --git a/.github/workflows/release-package.yml b/.github/workflows/release-package.yml index d9d4cc2..7f73d83 100644 --- a/.github/workflows/release-package.yml +++ b/.github/workflows/release-package.yml @@ -44,6 +44,9 @@ jobs: - name: Build package archive run: pnpm package:codex-claw + - name: Smoke packed CLI + run: pnpm smoke:codex-claw:pack + - name: Write SHA256 checksums shell: bash run: | diff --git a/README.md b/README.md index 151f92c..5e0bac0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ CodexClaw turns your installed codex command into a local web clien - [Terminal Demo](#terminal-demo) - [Configuration](#configuration) - [Common Commands](#common-commands) +- [Troubleshooting](#troubleshooting) - [How It Works](#how-it-works) - [Beta Track](#beta-track) - [Contributing](#contributing) @@ -75,13 +76,35 @@ codex exec "Reply with: ready" The public npm package target is codex-claw@0.1.0-alpha.0. -After the first npm alpha publish, the install path will be: +After the first npm alpha publish, start without a global install: + +~~~powershell +# Windows PowerShell +npx codex-claw@alpha +npm exec codex-claw@alpha -- doctor +~~~ ~~~bash +# macOS and Linux npx codex-claw@alpha +npm exec codex-claw@alpha -- doctor +~~~ + +For a pinned global CLI during alpha: + +~~~bash +npm install -g codex-claw@alpha +codex-claw doctor +codex-claw --help +~~~ + +Update by re-running npx codex-claw@alpha or reinstalling the alpha tag: + +~~~bash +npm install -g codex-claw@alpha ~~~ -Until then, use the source checkout above. The package is intentionally tagged as alpha so early releases do not claim a stable latest workflow. +Until the first alpha package exists on npm, use the source checkout above. The package is intentionally tagged as alpha so early releases do not claim a stable latest workflow. ## Terminal Demo @@ -102,7 +125,16 @@ Local source readiness check: ~~~console $ node packages/codex-claw/bin/codex-claw.js doctor -Environment looks good. +[ok] Node.js: Node.js 20.19.5 +[ok] npm: 10.8.2 +[warn] npm auth: npm auth unavailable. Run `npm login` before publishing codex-claw@alpha. +[ok] pnpm: 9.15.4 +[ok] git: git version 2.51.0.windows.1 +[ok] git worktree: Current directory is a git worktree. +[ok] Codex CLI: codex-cli 0.61.0 +[ok] state directory: .codex-claw can be created on first run. +[ok] port: Port 3000 is available. +Environment is usable with 1 warning(s). ~~~ Manual source workflow: @@ -141,9 +173,27 @@ pnpm lint # run ESLint pnpm landing:dev # start the landing page pnpm landing:build # build the landing page pnpm pack:codex-claw # inspect npm package contents +pnpm smoke:codex-claw # run npx against the packed local tarball +pnpm smoke:codex-claw:npm # run npx against codex-claw@alpha once published pnpm release:codex-claw # publish alpha package with the alpha dist-tag ~~~ +## Troubleshooting + +| Symptom | What to run | +| --- | --- | +| npm auth unavailable | Run npm login, then npm whoami before publishing | +| codex-claw@alpha was not found on npm | The alpha package has not been published yet; use the source checkout or publish with pnpm release:codex-claw | +| Port 3000 is already in use | Stop the process using the port or run codex-claw doctor --port 3001 | +| Codex CLI was not found | Install Codex CLI, run codex login, or pass --codex-command <cmd> | + +Package readiness checks: + +~~~bash +pnpm smoke:codex-claw:pack +pnpm smoke:codex-claw:npm +~~~ + ## How It Works ~~~text diff --git a/package.json b/package.json index 81a1b64..a7018d6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "format": "pnpm -C apps/codex-claw format", "check": "pnpm -C apps/codex-claw check", "pack:codex-claw": "pnpm -C packages/codex-claw exec npm pack --dry-run", + "smoke:codex-claw": "node packages/codex-claw/scripts/install-smoke.mjs --source pack", + "smoke:codex-claw:pack": "node packages/codex-claw/scripts/install-smoke.mjs --source pack", + "smoke:codex-claw:npm": "node packages/codex-claw/scripts/install-smoke.mjs --source npm", "package:codex-claw": "node -e \"require('node:fs').mkdirSync('dist/release',{recursive:true})\" && pnpm -C packages/codex-claw exec npm pack --pack-destination ../../dist/release", "release:codex-claw": "pnpm -C packages/codex-claw exec npm publish --tag alpha --access public", "bump:codex-claw:patch": "pnpm -C packages/codex-claw version patch --no-git-tag-version", diff --git a/packages/codex-claw/README.md b/packages/codex-claw/README.md index c84d2c1..543dbd4 100644 --- a/packages/codex-claw/README.md +++ b/packages/codex-claw/README.md @@ -20,10 +20,25 @@ The package is designed for an npx codex-claw@alpha first-run workf ## Alpha Install -After the first public npm publish: +After the first public npm publish, use npx for the cleanest alpha path: + +~~~powershell +# Windows PowerShell +npx codex-claw@alpha +npm exec codex-claw@alpha -- doctor +~~~ ~~~bash +# macOS and Linux npx codex-claw@alpha +npm exec codex-claw@alpha -- doctor +~~~ + +Global alpha install: + +~~~bash +npm install -g codex-claw@alpha +codex-claw doctor ~~~ Useful non-interactive bootstrap: @@ -35,6 +50,12 @@ pnpm install pnpm dev ~~~ +Update by re-running npx codex-claw@alpha or reinstalling the alpha tag: + +~~~bash +npm install -g codex-claw@alpha +~~~ + ## Local Development Usage From this repository: @@ -55,7 +76,7 @@ node packages/codex-claw/bin/codex-claw.js doctor | codex-claw preview | Preview the production build | | codex-claw test | Run tests | | codex-claw lint | Run lint | -| codex-claw doctor | Validate Node.js, pnpm, and Codex CLI | +| codex-claw doctor | Validate Node.js, npm auth, pnpm, Git, Codex CLI, state directory, and dev port availability | ## Prompts @@ -84,7 +105,20 @@ Then it creates the project folder, installs dependencies, and starts CodexClaw npm whoami npm view codex-claw version dist-tags --json pnpm pack:codex-claw +pnpm smoke:codex-claw:pack +pnpm smoke:codex-claw:npm pnpm release:codex-claw ~~~ The release script publishes with the alpha dist-tag so early builds stay clearly separated from a future stable channel. + +## Troubleshooting + +| Symptom | Action | +| --- | --- | +| npm auth unavailable | Run npm login, then npm whoami before publishing | +| codex-claw@alpha was not found on npm | The alpha package is not published yet; use the source checkout or publish with pnpm release:codex-claw | +| Port 3000 is already in use | Stop the existing process or run codex-claw doctor --port 3001 | +| Codex CLI was not found | Install Codex CLI, run codex login, or pass --codex-command <cmd> | + +The npm smoke test intentionally fails with a package-not-found message until the alpha package is available on npm. diff --git a/packages/codex-claw/bin/codex-claw.js b/packages/codex-claw/bin/codex-claw.js index 1a73148..e579cad 100755 --- a/packages/codex-claw/bin/codex-claw.js +++ b/packages/codex-claw/bin/codex-claw.js @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from 'node:fs' +import net from 'node:net' import os from 'node:os' import path from 'node:path' import { spawnSync } from 'node:child_process' @@ -39,8 +40,10 @@ function printHelp() { process.stdout.write(` --codex-sandbox CODEX_CLI_SANDBOX value\n`) process.stdout.write(` --codex-workdir CODEX_CLI_WORKDIR value\n`) process.stdout.write(` --port Dev server port\n`) + process.stdout.write(` --state-dir CODEX_CLAW_STATE_DIR value for doctor\n`) process.stdout.write(` --yes Accept defaults (non-interactive)\n`) process.stdout.write(` --no-start Do not auto-run install + dev\n`) + process.stdout.write(` --no-port-check Skip doctor port availability check\n`) process.stdout.write(` --force Allow init in non-empty directory\n`) process.stdout.write(` --skip-env Skip .env.local setup prompts\n`) process.stdout.write(` -h, --help Show help\n`) @@ -56,6 +59,7 @@ function parseCliArgs(args) { '--codex-sandbox', '--codex-workdir', '--port', + '--state-dir', ]) for (let index = 0; index < args.length; index += 1) { @@ -377,20 +381,6 @@ function startProject(targetDir) { runCommand(packageManager, ['run', 'dev'], targetDir) } -function hasCommand(command) { - if (process.platform === 'win32') { - return spawnSync(`${command} --version`, { - stdio: 'ignore', - shell: true, - }).status === 0 - } - - if (spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0) { - return true - } - return false -} - async function initProject(rawTarget, options, bootstrapConfig) { if (!bootstrapConfig) { printBanner() @@ -519,31 +509,277 @@ async function askBootstrapConfig(defaultProjectName, parsedArgs) { } } -function doctor() { - const nodeMajor = Number(process.versions.node.split('.')[0] || 0) - const hasPnpm = hasCommand('pnpm') - const hasCodex = hasCommand('codex') - const issues = [] +function quoteShellArg(value) { + const text = String(value) + if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) { + return text + } + return `"${text.replace(/"/g, '\\\\"')}"` +} + +function commandNeedsShell(command) { + return process.platform === 'win32' || /\s/.test(command) +} + +function runCommandCapture(command, args = [], cwd = process.cwd()) { + const useShell = commandNeedsShell(command) + const result = useShell + ? spawnSync([command, ...args.map(quoteShellArg)].join(' '), { + cwd, + encoding: 'utf8', + env: process.env, + shell: true, + stdio: 'pipe', + }) + : spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: process.env, + stdio: 'pipe', + }) + + return { + error: result.error, + status: typeof result.status === 'number' ? result.status : 1, + stdout: result.stdout ? result.stdout.trim() : '', + stderr: result.stderr ? result.stderr.trim() : '', + } +} + +function firstOutputLine(result) { + const output = result.stdout || result.stderr + return output.split(/\r?\n/).find((line) => line.trim().length > 0) || '' +} + +function createDoctorCheck(status, label, message) { + return { status, label, message } +} +function checkCommandVersion(label, command, args, missingMessage) { + const result = runCommandCapture(command, args) + if (result.status === 0) { + const version = firstOutputLine(result) + return createDoctorCheck( + 'ok', + label, + version.length > 0 ? version : `${command} is available.`, + ) + } + + const reason = result.error?.message || firstOutputLine(result) + const suffix = reason.length > 0 ? ` (${reason})` : '' + return createDoctorCheck('fail', label, `${missingMessage}${suffix}`) +} + +function checkNodeVersion() { + const nodeMajor = Number(process.versions.node.split('.')[0] || 0) if (nodeMajor < 20) { - issues.push('Node.js >= 20 is required.') + return createDoctorCheck( + 'fail', + 'Node.js', + `Node.js >= 20 is required. Found ${process.versions.node}.`, + ) } - if (!hasPnpm) { - issues.push('pnpm is recommended but was not found in PATH.') + + return createDoctorCheck('ok', 'Node.js', `Node.js ${process.versions.node}`) +} + +function checkNpmAuth() { + const result = runCommandCapture('npm', ['whoami']) + if (result.status === 0) { + return createDoctorCheck('ok', 'npm auth', `Authenticated as ${result.stdout}.`) } - if (!hasCodex) { - issues.push('Codex CLI was not found in PATH.') + + return createDoctorCheck( + 'warn', + 'npm auth', + 'npm auth unavailable. Run `npm login` before publishing codex-claw@alpha.', + ) +} + +function checkGitWorktree() { + const result = runCommandCapture('git', ['rev-parse', '--is-inside-work-tree']) + if (result.status === 0 && result.stdout === 'true') { + return createDoctorCheck('ok', 'git worktree', 'Current directory is a git worktree.') } - if (issues.length === 0) { - process.stdout.write('Environment looks good.\n') - return + return createDoctorCheck( + 'warn', + 'git worktree', + 'Current directory is not a git worktree. Bootstrap creates one for new projects.', + ) +} + +function resolveDoctorStateDir(parsedArgs) { + const stateDir = + parsedArgs.values.get('--state-dir') || + process.env.CODEX_CLAW_STATE_DIR || + path.join(process.cwd(), '.codex-claw') + return path.resolve(process.cwd(), stateDir) +} + +function checkStateDirectory(parsedArgs) { + const stateDir = resolveDoctorStateDir(parsedArgs) + const parentDir = path.dirname(stateDir) + + try { + if (fs.existsSync(stateDir)) { + const stat = fs.statSync(stateDir) + if (!stat.isDirectory()) { + return createDoctorCheck( + 'fail', + 'state directory', + `${stateDir} exists but is not a directory.`, + ) + } + + fs.accessSync(stateDir, fs.constants.R_OK | fs.constants.W_OK) + const probePath = path.join( + stateDir, + `.doctor-${process.pid}-${Date.now()}.tmp`, + ) + fs.writeFileSync(probePath, 'ok\n') + fs.rmSync(probePath, { force: true }) + return createDoctorCheck( + 'ok', + 'state directory', + `${stateDir} is writable.`, + ) + } + + if (!fs.existsSync(parentDir)) { + return createDoctorCheck( + 'fail', + 'state directory', + `Parent directory does not exist for ${stateDir}.`, + ) + } + + fs.accessSync(parentDir, fs.constants.W_OK) + return createDoctorCheck( + 'ok', + 'state directory', + `${stateDir} can be created on first run.`, + ) + } catch (error) { + return createDoctorCheck( + 'fail', + 'state directory', + `${stateDir} is not writable: ${error instanceof Error ? error.message : String(error)}`, + ) } +} + +function checkPortAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer() + let settled = false - for (const issue of issues) { - process.stderr.write(`- ${issue}\n`) + function finish(check) { + if (settled) return + settled = true + resolve(check) + } + + server.once('error', (error) => { + if (error && error.code === 'EADDRINUSE') { + finish( + createDoctorCheck( + 'fail', + 'port', + `Port ${port} is already in use. Re-run with --port or stop the existing process.`, + ), + ) + return + } + + finish( + createDoctorCheck( + 'fail', + 'port', + `Port ${port} is not available: ${error instanceof Error ? error.message : String(error)}`, + ), + ) + }) + + server.once('listening', () => { + server.close(() => { + finish(createDoctorCheck('ok', 'port', `Port ${port} is available.`)) + }) + }) + + server.listen(port, '127.0.0.1') + }) +} + +function printDoctorChecks(checks) { + for (const check of checks) { + process.stdout.write(`[${check.status}] ${check.label}: ${check.message}\n`) } - process.exit(1) +} + +async function doctor(parsedArgs) { + const codexCommand = + parsedArgs.values.get('--codex-command') || + process.env.CODEX_CLI_COMMAND || + 'codex' + const port = parsePort(parsedArgs.values.get('--port') || process.env.PORT || 3000, 3000) + const checks = [ + checkNodeVersion(), + checkCommandVersion( + 'npm', + 'npm', + ['--version'], + 'npm was not found in PATH. Install Node.js with npm, then retry.', + ), + checkNpmAuth(), + checkCommandVersion( + 'pnpm', + 'pnpm', + ['--version'], + 'pnpm was not found in PATH. Install it with `npm install -g pnpm` or Corepack.', + ), + checkCommandVersion( + 'git', + 'git', + ['--version'], + 'git was not found in PATH. Install Git before bootstrapping CodexClaw.', + ), + checkGitWorktree(), + checkCommandVersion( + 'Codex CLI', + codexCommand, + ['--version'], + `Codex CLI was not found with command \`${codexCommand}\`. Install Codex CLI, run \`codex login\`, or pass --codex-command .`, + ), + checkStateDirectory(parsedArgs), + ] + + if (parsedArgs.flags.has('--no-port-check')) { + checks.push(createDoctorCheck('warn', 'port', 'Port availability check skipped.')) + } else { + checks.push(await checkPortAvailable(port)) + } + + printDoctorChecks(checks) + + const failures = checks.filter((check) => check.status === 'fail') + const warnings = checks.filter((check) => check.status === 'warn') + if (failures.length > 0) { + process.stderr.write( + `CodexClaw doctor found ${failures.length} blocking issue(s).\n`, + ) + process.exit(1) + } + + if (warnings.length > 0) { + process.stdout.write( + `Environment is usable with ${warnings.length} warning(s).\n`, + ) + return + } + + process.stdout.write('Environment looks good.\n') } async function main() { @@ -571,7 +807,7 @@ async function main() { } if (command === 'doctor') { - doctor() + await doctor(parsedArgs) return } diff --git a/packages/codex-claw/scripts/install-smoke.mjs b/packages/codex-claw/scripts/install-smoke.mjs new file mode 100644 index 0000000..6970f63 --- /dev/null +++ b/packages/codex-claw/scripts/install-smoke.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const packageDir = path.resolve(__dirname, '..') +const packageName = 'codex-claw' +const alphaSpec = packageName + '@alpha' + +function parseArgs(args) { + let source = 'pack' + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--source' && args[index + 1]) { + source = args[index + 1] + index += 1 + continue + } + + if (arg.startsWith('--source=')) { + source = arg.slice('--source='.length) + continue + } + + if (arg === '-h' || arg === '--help') { + process.stdout.write('Usage: node scripts/install-smoke.mjs --source \n') + process.exit(0) + } + } + + if (!['pack', 'npm', 'all'].includes(source)) { + throw createFailure('Invalid --source value. Use pack, npm, or all.') + } + + return { source } +} + +function resolveNpmCliPath() { + const nodeDir = path.dirname(process.execPath) + const prefixDir = path.resolve(nodeDir, '..') + const candidates = [ + path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + path.join(prefixDir, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + path.join(prefixDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ] + + return candidates.find((candidate) => fs.existsSync(candidate)) || null +} + +function runNpm(args, cwd) { + const npmCliPath = resolveNpmCliPath() + if (npmCliPath) { + return spawnSync(process.execPath, [npmCliPath, ...args], { + cwd, + encoding: 'utf8', + env: process.env, + stdio: 'pipe', + }) + } + + return spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, { + cwd, + encoding: 'utf8', + env: process.env, + stdio: 'pipe', + }) +} + +function createFailure(message, result) { + return { message, result } +} + +function assertSuccess(result, message) { + if (result.status === 0) { + return + } + throw createFailure(message, result) +} + +function printCaptured(result) { + if (!result) return + if (result.stdout && result.stdout.trim().length > 0) { + process.stderr.write(result.stdout.trim() + '\n') + } + if (result.stderr && result.stderr.trim().length > 0) { + process.stderr.write(result.stderr.trim() + '\n') + } + if (result.error) { + process.stderr.write(result.error.message + '\n') + } +} + +function findPackedTarball(result, tempDir) { + const lines = result.stdout.split(/\r?\n/).map((line) => line.trim()) + const tarballName = [...lines].reverse().find((line) => line.endsWith('.tgz')) + if (!tarballName) { + throw createFailure('npm pack did not report a .tgz artifact.', result) + } + + const tarballPath = path.resolve(tempDir, tarballName) + if (!fs.existsSync(tarballPath)) { + throw createFailure('Packed tarball was not written to ' + tarballPath + '.', result) + } + + return tarballPath +} + +function runPackSmoke() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-claw-pack-')) + + try { + process.stdout.write('Packing local codex-claw package...\n') + const packResult = runNpm(['pack', '--pack-destination', tempDir], packageDir) + assertSuccess(packResult, 'npm pack failed for the local codex-claw package.') + + const tarballPath = findPackedTarball(packResult, tempDir) + process.stdout.write('Running npx-compatible smoke test from packed tarball...\n') + const npxResult = runNpm( + ['exec', '--yes', '--package', tarballPath, '--', packageName, '--help'], + packageDir, + ) + assertSuccess( + npxResult, + 'npx could not run codex-claw from the packed tarball.', + ) + process.stdout.write('Packed tarball smoke test passed.\n') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +} + +function runNpmSmoke() { + process.stdout.write('Checking published codex-claw alpha package...\n') + const viewResult = runNpm(['view', alphaSpec, 'version'], packageDir) + if (viewResult.status !== 0) { + const combinedOutput = ((viewResult.stdout || '') + '\n' + (viewResult.stderr || '')).trim() + if (/E404|404|not found|No match found/i.test(combinedOutput)) { + throw createFailure( + 'codex-claw@alpha was not found on npm. Publish with pnpm release:codex-claw after npm login, then rerun this smoke test.', + viewResult, + ) + } + if (/ENEEDAUTH|E401|E403|auth/i.test(combinedOutput)) { + throw createFailure( + 'npm auth unavailable. Run npm login before publishing or checking restricted package metadata.', + viewResult, + ) + } + throw createFailure('npm could not read codex-claw@alpha metadata.', viewResult) + } + + const version = viewResult.stdout.trim() + process.stdout.write('Found codex-claw@alpha version ' + version + '.\n') + process.stdout.write('Running npx-compatible smoke test from npm alpha...\n') + const npxResult = runNpm( + ['exec', '--yes', '--package', alphaSpec, '--', packageName, '--help'], + packageDir, + ) + assertSuccess(npxResult, 'npx could not run codex-claw@alpha from npm.') + process.stdout.write('npm alpha smoke test passed.\n') +} + +function main() { + const { source } = parseArgs(process.argv.slice(2)) + + if (source === 'pack' || source === 'all') { + runPackSmoke() + } + + if (source === 'npm' || source === 'all') { + runNpmSmoke() + } +} + +try { + main() +} catch (error) { + const message = error && typeof error === 'object' && 'message' in error + ? error.message + : String(error) + process.stderr.write(message + '\n') + printCaptured(error && typeof error === 'object' ? error.result : null) + process.exit(1) +}