diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 93d2054ef..a9cb33f6e 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -29,10 +29,32 @@ function run(cmd, options = {}) { // --------------------------------------------------------------------------- process.stdout.write = process.stderr.write.bind(process.stderr) +// --------------------------------------------------------------------------- +// ASCII banner — printed before clack UI for brand recognition +// --------------------------------------------------------------------------- +const cyan = '\x1b[36m' +const dim = '\x1b[2m' +const reset = '\x1b[0m' + +const banner = + '\n' + + cyan + + ' ██████╗ ███████╗██████╗ \n' + + ' ██╔════╝ ██╔════╝██╔══██╗\n' + + ' ██║ ███╗███████╗██║ ██║\n' + + ' ██║ ██║╚════██║██║ ██║\n' + + ' ╚██████╔╝███████║██████╔╝\n' + + ' ╚═════╝ ╚══════╝╚═════╝ ' + + reset + '\n' + + '\n' + + ` Get Shit Done ${dim}v${pkg.version}${reset}\n` + // --------------------------------------------------------------------------- // Main — wrapped in async IIFE, with graceful fallback if clack fails // --------------------------------------------------------------------------- ;(async () => { + process.stderr.write(banner) + let p, pc try { @@ -40,15 +62,14 @@ process.stdout.write = process.stderr.write.bind(process.stderr) pc = (await import('picocolors')).default } catch { // Clack or picocolors unavailable — fall back to minimal output - process.stderr.write(`\n GSD v${pkg.version} installed.\n Run gsd to get started.\n\n`) + process.stderr.write(` Run gsd to get started.\n\n`) await run('npx patch-package') - const args = os.platform() === 'linux' ? '--with-deps' : '' - await run(`npx playwright install chromium ${args}`) + await run('npx playwright install chromium') return } // --- Branded intro ------------------------------------------------------- - p.intro(pc.bgCyan(pc.black(' gsd ')) + ' ' + pc.dim(`v${pkg.version}`)) + p.intro('Setup') const results = [] const s = p.spinner() @@ -68,18 +89,28 @@ process.stdout.write = process.stderr.write.bind(process.stderr) } // --- Step 2: Playwright browser ------------------------------------------ + // Avoid --with-deps: install scripts should not block on interactive sudo + // prompts. If Linux libs are missing, suggest the explicit follow-up. s.start('Setting up browser tools…') - const pwArgs = os.platform() === 'linux' ? ' --with-deps' : '' - const pwResult = await run(`npx playwright install chromium${pwArgs}`) + const pwResult = await run('npx playwright install chromium') if (pwResult.ok) { s.stop('Browser tools ready') results.push({ label: 'Browser tools ready', ok: true }) } else { - s.stop(pc.yellow('Browser tools — skipped (non-fatal)')) - results.push({ - label: 'Browser tools unavailable — run ' + pc.cyan('npx playwright install chromium'), - ok: false, - }) + const output = `${pwResult.stdout ?? ''}${pwResult.stderr ?? ''}` + if (os.platform() === 'linux' && output.includes('Host system is missing dependencies to run browsers.')) { + s.stop(pc.yellow('Browser downloaded, missing Linux deps')) + results.push({ + label: 'Run ' + pc.cyan('sudo npx playwright install-deps chromium') + ' to finish setup', + ok: false, + }) + } else { + s.stop(pc.yellow('Browser tools — skipped (non-fatal)')) + results.push({ + label: 'Browser tools unavailable — run ' + pc.cyan('npx playwright install chromium'), + ok: false, + }) + } } // --- Summary note -------------------------------------------------------- diff --git a/src/loader.ts b/src/loader.ts index 368d46565..83e660093 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,8 +1,9 @@ #!/usr/bin/env node import { fileURLToPath } from 'url' import { dirname, resolve, join } from 'path' -import { readFileSync } from 'fs' -import { agentDir } from './app-paths.js' +import { existsSync, readFileSync } from 'fs' +import { agentDir, appRoot } from './app-paths.js' +import { renderLogo } from './logo.js' // pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's // theme assets (dist/modes/interactive/theme/) without a src/ directory. @@ -17,7 +18,25 @@ process.env.PI_PACKAGE_DIR = pkgDir process.env.PI_SKIP_VERSION_CHECK = '1' // GSD ships its own update check — suppress pi's process.title = 'gsd' -// First-launch branding is handled by the onboarding wizard (src/onboarding.ts) +// Print branded banner on first launch (before ~/.gsd/ exists) +if (!existsSync(appRoot)) { + const cyan = '\x1b[36m' + const green = '\x1b[32m' + const dim = '\x1b[2m' + const reset = '\x1b[0m' + const colorCyan = (s: string) => `${cyan}${s}${reset}` + let version = '' + try { + const pkgJson = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8')) + version = pkgJson.version ?? '' + } catch { /* ignore */ } + process.stderr.write( + renderLogo(colorCyan) + + '\n' + + ` Get Shit Done ${dim}v${version}${reset}\n` + + ` ${green}Welcome.${reset} Setting up your environment...\n\n` + ) +} // GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/ process.env.GSD_CODING_AGENT_DIR = agentDir diff --git a/src/logo.ts b/src/logo.ts index 206406471..dc89200ad 100644 --- a/src/logo.ts +++ b/src/logo.ts @@ -3,11 +3,11 @@ * * Single source of truth — imported by: * - scripts/postinstall.js (via dist/logo.js) - * - src/onboarding.ts (via ./logo.js) + * - src/loader.ts (via ./logo.js) */ /** Raw logo lines — no ANSI codes, no leading newline. */ -export const GSD_LOGO: string[] = [ +export const GSD_LOGO: readonly string[] = [ ' ██████╗ ███████╗██████╗ ', ' ██╔════╝ ██╔════╝██╔══██╗', ' ██║ ███╗███████╗██║ ██║', @@ -19,7 +19,7 @@ export const GSD_LOGO: string[] = [ /** * Render the logo block with a color function applied to each line. * - * @param color — e.g. picocolors.cyan or `(s) => `\x1b[36m${s}\x1b[0m`` + * @param color — e.g. `(s) => `\x1b[36m${s}\x1b[0m`` or picocolors.cyan * @returns Ready-to-write string with leading/trailing newlines. */ export function renderLogo(color: (s: string) => string): string { diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 3221f66c5..52c694125 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1023,14 +1023,35 @@ async function dispatchNextUnit( midTitle = state.activeMilestone?.title; } catch (error) { const message = error instanceof Error ? error.message : String(error); + + // Safety net: if mergeSliceToMain failed to clean up (or the error + // came from switchToMain), ensure the working tree isn't left in a + // conflicted/dirty merge state. Without this, state derivation reads + // conflict-marker-filled files, produces a corrupt phase, and + // dispatch loops forever (see: merge-bug-fix). + try { + const { runGit } = await import("./git-service.ts"); + const status = runGit(basePath, ["status", "--porcelain"], { allowFailure: true }); + if (status && (status.includes("UU ") || status.includes("AA ") || status.includes("UD "))) { + runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true }); + ctx.ui.notify( + `Cleaned up conflicted merge state after failed squash-merge.`, + "warning", + ); + } + } catch { /* best-effort cleanup */ } + ctx.ui.notify( - `Slice merge failed: ${message}`, + `Slice merge failed — stopping auto-mode. Fix conflicts manually and restart.\n${message}`, "error", ); - // Re-derive state so dispatch can figure out what to do - state = await deriveState(basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; + if (currentUnit) { + const modelId = ctx.model?.id ?? "unknown"; + snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); + saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id); + } + await stopAuto(ctx, pi); + return; } } } diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 5957e28f3..2791dce49 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -446,8 +446,22 @@ export class GitServiceImpl { commitType, milestoneId, sliceId, sliceTitle, mainBranch, branch, ); - // Squash merge - this.git(["merge", "--squash", branch]); + // Squash merge — abort cleanly on conflict so the working tree is never + // left in a half-merged state (see: merge-bug-fix). + try { + this.git(["merge", "--squash", branch]); + } catch (mergeError) { + // git merge --squash exits non-zero on conflict. The working tree now + // has conflict markers and a dirty index. Reset to restore a clean state. + this.git(["reset", "--hard", "HEAD"], { allowFailure: true }); + const msg = mergeError instanceof Error ? mergeError.message : String(mergeError); + throw new Error( + `Squash-merge of "${branch}" into "${mainBranch}" failed with conflicts. ` + + `Working tree has been reset to a clean state. ` + + `Resolve manually: git checkout ${mainBranch} && git merge --squash ${branch}\n` + + `Original error: ${msg}`, + ); + } // Commit with rich message via stdin pipe this.git(["commit", "-F", "-"], { input: message });