From 2c82c2278fe048071dc40d3c70343698c1848ad3 Mon Sep 17 00:00:00 2001 From: Ame Date: Fri, 19 Jun 2026 17:21:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(workspaces):=20native=20cross-platform=20b?= =?UTF-8?q?ootstrap=20=E2=80=94=20bundle=20git=20(dugite)=20+=20port=20bas?= =?UTF-8?q?h=20to=20Node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace creation (the Harness: every workspace is a fresh git repo) had two external deps that break on a bare machine: bash (the bootstrap scripts) and system git. bash is absent on Windows; git is latent on Mac too (a fresh Mac's /usr/bin/git is an Xcode-CLT stub — our dev Macs only have it because CLT was installed long ago). So workspace creation failed on a bare Windows box, and would on a bare Mac. Now it needs neither: - Bundle git via dugite (GitHub Desktop's package; postinstall fetches a per-platform standalone git — same per-platform model as longbridge, picked up by electron-builder's node_modules inclusion under asar:false). - Port the bash bootstraps to Node: templates/_common.mjs (the sole dugite importer, exposes git()), chat/bootstrap.mjs, auto-quant/bootstrap.mjs. The launcher spawns them on the Electron-bundled Node (process.execPath + ELECTRON_RUN_AS_NODE) — no bash, no shebang reliance, plain ESM. - Route ALL git through the bundled git: runGit (initial commit) and git-service (panel log/branch/status) → dugite's exec(). - template-registry prefers bootstrap.mjs, falls back to bootstrap.sh for third-party templates (which still need bash where they run). dugite MUST stay in pnpm.onlyBuiltDependencies — its postinstall fetches the git binary; drop it and node_modules/dugite/git/ is silently empty. Release CI asserts the binary is present per-platform. Verified: tsc clean, 2003 unit tests + e2e (incl a PATH-stripped case proving no system git/bash) green; packaged .app carries dugite's git 2.53.0 + the .mjs templates. Closes the bare-Windows / bare-Mac workspace-creation gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 7 + CLAUDE.md | 4 +- package.json | 4 +- pnpm-lock.yaml | 159 ++++++++++++++++++ src/core/paths.ts | 5 +- src/workspaces/git-service.ts | 39 +++-- src/workspaces/template-registry.ts | 14 +- src/workspaces/templates/_common.mjs | 80 +++++++++ src/workspaces/templates/_common.sh | 97 ----------- .../templates/auto-quant/bootstrap.mjs | 102 +++++++++++ .../templates/auto-quant/bootstrap.sh | 115 ------------- src/workspaces/templates/chat/bootstrap.mjs | 30 ++++ src/workspaces/templates/chat/bootstrap.sh | 28 --- src/workspaces/workspace-creation.e2e.spec.ts | 41 +++-- src/workspaces/workspace-creator.spec.ts | 41 +++++ src/workspaces/workspace-creator.ts | 55 +++--- 16 files changed, 527 insertions(+), 294 deletions(-) create mode 100644 src/workspaces/templates/_common.mjs delete mode 100755 src/workspaces/templates/_common.sh create mode 100644 src/workspaces/templates/auto-quant/bootstrap.mjs delete mode 100755 src/workspaces/templates/auto-quant/bootstrap.sh create mode 100644 src/workspaces/templates/chat/bootstrap.mjs delete mode 100755 src/workspaces/templates/chat/bootstrap.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5e6a4439..2abbbba58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -171,6 +171,13 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Fail loud if dugite's postinstall was skipped (e.g. dropped from + # pnpm.onlyBuiltDependencies) — otherwise node_modules/dugite/git/ is + # silently empty and workspace creation breaks at runtime, not build. + - name: Verify bundled git (dugite) was fetched + run: | + node -e "const{existsSync}=require('fs');const{dirname,join}=require('path');const g=join(dirname(require.resolve('dugite/package.json')),'git');if(!existsSync(g)){console.error('dugite embedded git MISSING at '+g);process.exit(1)}console.log('dugite embedded git OK '+g)" + - name: Build Alice + UTA + desktop shell run: pnpm electron:build diff --git a/CLAUDE.md b/CLAUDE.md index a303ffa1e..140e4ad14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,9 @@ Notes: ### Cross-platform note -Workspace bootstrap scripts (`src/workspaces/templates/*/bootstrap.sh`) are bash-based. On Windows they require `bash` from Git for Windows (default install) or WSL2. `workspace-creator.ts` already platform-branches the spawn so the same script paths work on win32 — when adding a new template, write bash as usual, but **don't** add POSIX-only commands without checking they ship with Git for Windows's bundled MSYS env (sed/cp/mkdir/basename/printf/source/[[ ]] all work; obscure tools like `jq` do not). See README's *Windows* section for the user-facing story. +Workspace bootstrap is **cross-platform Node** — built-in templates ship `src/workspaces/templates//bootstrap.mjs` (plain ESM, no TypeScript syntax). The launcher (`workspace-creator.ts` `runScript`) spawns them on the Electron-bundled Node (`process.execPath` + `ELECTRON_RUN_AS_NODE`), and **all git goes through bundled git** (`dugite`) via `_common.mjs`'s `git()` helper. Net effect: workspace creation works on a **bare Windows or bare Mac** — no bash, no Git for Windows, no system git. When adding a template, write a `bootstrap.mjs` that imports `../_common.mjs` (`initWorkspaceDir` / `copyReadme` / `setupGitExcludes` / `git`) and routes every git call through `git()` — never `spawn('git')`. + +`bootstrap.sh` is still supported as a **fallback** for third-party/satellite templates that ship bash (`template-registry` prefers `.mjs`, falls back to `.sh`); those only run where `bash` is on PATH (Git for Windows / WSL2). Don't add new `.sh` bootstraps for in-repo templates. The critical packaging invariant: `dugite` must stay in `pnpm.onlyBuiltDependencies` (its postinstall fetches the per-platform git; drop it and `node_modules/dugite/git/` is silently empty — release CI asserts it's present). See README's *Windows* section for the user-facing story. ## Subsystem guides diff --git a/package.json b/package.json index 98bbb7ecd..c00238b69 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "ajv": "^8.18.0", "ccxt": "^4.5.38", "decimal.js": "^10.6.0", + "dugite": "3.2.2", "grammy": "^1.40.0", "hono": "^4.12.21", "longbridge": "^4.0.5", @@ -98,7 +99,8 @@ "pnpm": { "onlyBuiltDependencies": [ "node-pty", - "electron" + "electron", + "dugite" ], "overrides": { "@alpacahq/alpaca-trade-api>axios": "^0.32.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6efbacc3e..8ca3d821e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: decimal.js: specifier: ^10.6.0 version: 10.6.0 + dugite: + specifier: 3.2.2 + version: 3.2.2 grammy: specifier: ^1.40.0 version: 1.40.0(encoding@0.1.13) @@ -1790,6 +1793,14 @@ packages: axios@0.32.0: resolution: {integrity: sha512-sGQArzERW2SI8IRkjuJ5y91Sm9QjiRq4Ay4kOLqpbBt5CeKDNq4g6nirJdyD+palK3yEDXnJiVXsesX66AjwyA==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1797,6 +1808,47 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.2: + resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.1: + resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2189,6 +2241,10 @@ packages: dprint-node@1.0.8: resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} + dugite@3.2.2: + resolution: {integrity: sha512-pGTVaxea0WqauGXF5A3GmpmPOim6oTnfYM6dS6s8nHyJxf4pRTjwtFHkqLkT5de87uUPhTI+LtlmcJQ2WkqeGA==} + engines: {node: '>= 20'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2325,6 +2381,9 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -2368,6 +2427,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} @@ -3731,6 +3793,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -3782,10 +3847,16 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + tar@7.5.16: resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} engines: {node: '>=18'} + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -3793,6 +3864,9 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5708,10 +5782,45 @@ snapshots: transitivePeerDependencies: - debug + b4a@1.8.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-events@2.9.1: {} + + bare-fs@4.7.2: + dependencies: + bare-events: 2.9.1 + bare-path: 3.0.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.1: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.5: + dependencies: + bare-path: 3.0.1 + base64-js@1.5.1: {} baseline-browser-mapping@2.10.7: {} @@ -6097,6 +6206,15 @@ snapshots: dependencies: detect-libc: 1.0.3 + dugite@3.2.2: + dependencies: + progress: 2.0.3 + tar-stream: 3.2.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6301,6 +6419,12 @@ snapshots: eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} eventsource-parser@3.0.6: {} @@ -6367,6 +6491,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-sha256@1.3.0: {} fast-string-truncated-width@3.0.3: {} @@ -7716,6 +7842,15 @@ snapshots: std-env@4.1.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-width@4.2.3: @@ -7772,6 +7907,17 @@ snapshots: tapable@2.3.0: {} + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.2 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + tar@7.5.16: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -7780,6 +7926,13 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + temp-file@3.4.0: dependencies: async-exit-hook: 2.0.1 @@ -7790,6 +7943,12 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 diff --git a/src/core/paths.ts b/src/core/paths.ts index b14ff0256..45143bd04 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -64,9 +64,8 @@ export function uiBundlePath(): string { * default/ does: dev points to repo source, packaged points to wherever * the bundler copied the templates inside .app/Contents/Resources/. * - * NOTE: For packaged .app distribution, build.files in package.json must - * include `src/workspaces/templates/**` (currently DOES NOT — workspace - * spawning will fail until that's added; tracked in TODO). + * `build.files` in package.json ships `src/workspaces/templates/**`, so the + * `.mjs` bootstraps + their READMEs land in the packaged .app. */ export function templatesPath(): string { return resolve(APP_RESOURCES_HOME, 'src', 'workspaces', 'templates') diff --git a/src/workspaces/git-service.ts b/src/workspaces/git-service.ts index d07795f77..a1572726a 100644 --- a/src/workspaces/git-service.ts +++ b/src/workspaces/git-service.ts @@ -1,7 +1,4 @@ -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - -const exec = promisify(execFile); +import { exec, type IGitStringExecutionOptions } from 'dugite'; export interface GitLogEntry { readonly hash: string; @@ -24,6 +21,16 @@ export interface GitStatus { const LOG_FORMAT = '%h%x09%ar%x09%aI%x09%s'; const GIT_TIMEOUT_MS = 5_000; +const MAX_BUFFER = 4 * 1024 * 1024; + +// Read-only panel git, routed through the bundled git (dugite) so the panel +// renders without a system git. dugite resolves with an exitCode rather than +// throwing (it rejects only when git fails to launch, or when the abort signal +// fires), so callers check exitCode. The old execFile `timeout` option becomes +// an AbortSignal — a slow git is killed and surfaces as a rejection/non-zero. +function gitOpts(): IGitStringExecutionOptions { + return { maxBuffer: MAX_BUFFER, signal: AbortSignal.timeout(GIT_TIMEOUT_MS) }; +} /** * Wrap `git log --pretty=...` so the panel can render hash + subject + time. @@ -31,11 +38,14 @@ const GIT_TIMEOUT_MS = 5_000; */ export async function gitLog(cwd: string, limit: number): Promise { const n = Math.max(1, Math.min(limit, 500)); - const { stdout } = await exec( - 'git', + const { stdout, exitCode, stderr } = await exec( ['log', `--pretty=format:${LOG_FORMAT}`, `-n`, String(n)], - { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, + cwd, + gitOpts(), ); + if (exitCode !== 0) { + throw new Error(`git log exited ${exitCode}: ${stderr.slice(0, 200)}`); + } const lines = stdout.split('\n').filter((l) => l.length > 0); return lines.map((line) => { const parts = line.split('\t'); @@ -54,16 +64,13 @@ export async function gitLog(cwd: string, limit: number): Promise */ export async function gitStatus(cwd: string): Promise { const [branchRes, statusRes] = await Promise.all([ - exec('git', ['branch', '--show-current'], { cwd, timeout: GIT_TIMEOUT_MS }).catch( - () => ({ stdout: '' }), - ), - exec('git', ['status', '--porcelain=v1'], { - cwd, - timeout: GIT_TIMEOUT_MS, - maxBuffer: 4 * 1024 * 1024, - }), + exec(['branch', '--show-current'], cwd, gitOpts()).catch(() => null), + exec(['status', '--porcelain=v1'], cwd, gitOpts()), ]); - const branchRaw = branchRes.stdout.trim(); + if (statusRes.exitCode !== 0) { + throw new Error(`git status exited ${statusRes.exitCode}: ${statusRes.stderr.slice(0, 200)}`); + } + const branchRaw = branchRes && branchRes.exitCode === 0 ? branchRes.stdout.trim() : ''; const branch = branchRaw.length > 0 ? branchRaw : null; const files: GitStatusFile[] = statusRes.stdout .split('\n') diff --git a/src/workspaces/template-registry.ts b/src/workspaces/template-registry.ts index 6ec12ec09..e2961309e 100644 --- a/src/workspaces/template-registry.ts +++ b/src/workspaces/template-registry.ts @@ -120,8 +120,18 @@ export class TemplateRegistry { if (!entry.isDirectory()) continue; const name = entry.name; const templateDir = join(absDir, name); - const bootstrapScript = join(templateDir, 'bootstrap.sh'); - if (!existsSync(bootstrapScript)) { + // Prefer the cross-platform Node bootstrap (`bootstrap.mjs`, run on the + // bundled Node + bundled git — works on bare Windows/Mac). Fall back to + // `bootstrap.sh` for third-party templates that still ship bash (only + // runnable where bash is on PATH). + const mjsScript = join(templateDir, 'bootstrap.mjs'); + const shScript = join(templateDir, 'bootstrap.sh'); + const bootstrapScript = existsSync(mjsScript) + ? mjsScript + : existsSync(shScript) + ? shScript + : undefined; + if (bootstrapScript === undefined) { logger.warn('templates.no_bootstrap', { name, templateDir }); continue; } diff --git a/src/workspaces/templates/_common.mjs b/src/workspaces/templates/_common.mjs new file mode 100644 index 000000000..0c0f99b91 --- /dev/null +++ b/src/workspaces/templates/_common.mjs @@ -0,0 +1,80 @@ +/** + * Shared helpers for the Node workspace bootstrap scripts — the cross-platform + * port of `_common.sh`. + * + * Plain ESM, run directly by the Electron-bundled Node (the launcher spawns it + * via `process.execPath` + `ELECTRON_RUN_AS_NODE`). So: NO TypeScript syntax, + * only `node:*` builtins + `dugite`. Resolved via node walk-up from the + * template dir to the app's `node_modules` (works in dev and in the packaged + * `asar:false` app). + * + * This is the SOLE importer of `dugite` among the templates: all git goes + * through `git()` so workspace creation uses OpenAlice's bundled git — no + * system git, no Git-for-Windows, no bash. Bootstrap scripts call these + * helpers, never dugite directly (same shape as `_common.sh`'s sourced + * helpers). + */ + +import { existsSync, mkdirSync, copyFileSync, appendFileSync } from 'node:fs' +import { join } from 'node:path' +import { exec } from 'dugite' + +/** + * Run a git command via the bundled git, rooted at `cwd`. Throws on a non-zero + * exit — dugite resolves with `exitCode` rather than throwing, and only rejects + * when git itself fails to launch. Mirrors the launcher's `runGit` contract. + */ +export async function git(args, cwd) { + const r = await exec(args, cwd) + if (r.exitCode !== 0) { + throw new Error(`git ${args[0] ?? ''} exited ${r.exitCode}: ${String(r.stderr).slice(0, 500)}`) + } + return r +} + +/** + * Verify `outDir` doesn't yet exist, then create it. Does NOT chdir — callers + * build absolute paths off `outDir` (a spawned bootstrap can't rely on + * `process.cwd()`). Exit-2 semantics of the old bash helper become a throw. + */ +export function initWorkspaceDir(outDir) { + if (existsSync(outDir)) { + throw new Error(`outDir already exists: ${outDir}`) + } + mkdirSync(outDir, { recursive: true }) +} + +/** + * Copy `/README.md` into the workspace root so it's self- + * describing on disk. No-op when `templateRoot` is unset or has no README. + * `templateRoot` defaults to `AQ_TEMPLATE_ROOT` (injected by the launcher). + */ +export function copyReadme(outDir, templateRoot = process.env.AQ_TEMPLATE_ROOT) { + if (!templateRoot) return + const src = join(templateRoot, 'README.md') + if (!existsSync(src)) return + copyFileSync(src, join(outDir, 'README.md')) +} + +const DEFAULT_EXCLUDES = [ + '.claude/settings.local.json', + '.codex/auth.json', + '.codex/env.json', + '.codex/config.toml', + 'opencode.json', + '.pi-agent/', +] + +/** + * Append defensive entries to `/.git/info/exclude` (per-clone, + * untracked). Each can carry a per-workspace API key once a provider is + * configured, so they must never reach a commit. `extra` paths are appended + * too. Caller must have run `git init`/`clone` first (`.git/` must exist). + */ +export function setupGitExcludes(outDir, ...extra) { + if (!existsSync(join(outDir, '.git'))) { + throw new Error(`setupGitExcludes: no .git/ in ${outDir}`) + } + const lines = [...DEFAULT_EXCLUDES, ...extra].join('\n') + '\n' + appendFileSync(join(outDir, '.git', 'info', 'exclude'), lines) +} diff --git a/src/workspaces/templates/_common.sh b/src/workspaces/templates/_common.sh deleted file mode 100755 index 07172c1ac..000000000 --- a/src/workspaces/templates/_common.sh +++ /dev/null @@ -1,97 +0,0 @@ -# Shared bash helpers for workspace bootstrap scripts. -# -# Sourced by templates//bootstrap.sh via: -# source "$(dirname "${BASH_SOURCE[0]}")/../_common.sh" -# -# Each helper is self-contained and validates its own inputs. Helpers exit -# non-zero on irrecoverable errors so the launcher (workspace-creator.ts) -# surfaces the failure to the user via stderr. -# -# Templates that don't need a particular helper just don't call it — -# auto-quant for example only uses setup_git_excludes because it has its -# own clone-and-branch flow that init_workspace_dir would conflict with. - -# init_workspace_dir -# Verifies $out_dir doesn't yet exist, creates it, cd's into it. -init_workspace_dir() { - local out_dir="${1:?init_workspace_dir: out_dir required}" - if [[ -e "$out_dir" ]]; then - echo "outDir already exists: $out_dir" >&2 - exit 2 - fi - mkdir -p "$out_dir" - cd "$out_dir" -} - -# extract_ws_id -# Echoes the workspace UUID — by convention the basename of $out_dir -# (the launcher names the directory with the random UUID it assigns). -extract_ws_id() { - local out_dir="${1:?extract_ws_id: out_dir required}" - basename "$out_dir" -} - -# write_mcp_config + compose_persona_claude_md moved into the launcher -# (src/workspaces/context-injector.ts) — the launcher now owns MCP and persona -# injection, gated per template by template.json flags. - -# copy_readme [template_root] -# Copies $template_root/README.md into the current dir (the workspace root) -# so the workspace is self-describing on disk. The instance README is the -# agent's territory from this point on — body and frontmatter (including -# `version:`) can both drift. The pristine template README stays in source -# tree under $template_root and is what the showcase page renders. -# -# $template_root defaults to $AQ_TEMPLATE_ROOT (injected by the launcher). -# No-op if no README.md exists at the template root — templates without a -# README work fine, they just don't get a self-description file. That's a -# soft convention, not a hard contract. -copy_readme() { - local template_root="${1:-${AQ_TEMPLATE_ROOT:-}}" - if [[ -z "$template_root" ]]; then - echo "copy_readme: no template_root (set AQ_TEMPLATE_ROOT or pass arg)" >&2 - return 0 - fi - local src="$template_root/README.md" - if [[ ! -f "$src" ]]; then - return 0 - fi - cp "$src" README.md -} - -# setup_git_excludes [extra_path...] -# Appends defensive entries to .git/info/exclude (per-clone, untracked). -# Always includes: -# - .claude/settings.local.json (workspace-specific Claude config) -# - .codex/auth.json (workspace-local Codex auth) -# - .codex/env.json (workspace-local Codex API-key bridge) -# - .codex/config.toml (workspace-local Codex provider config) -# - opencode.json (workspace-local opencode provider config) -# - .pi-agent/ (workspace-local Pi provider + settings) -# All five carry a per-workspace API key once a provider is configured (UI or -# template-injected), so they must never reach a commit. -# Extra paths passed as args are appended too — useful for templates that -# clone third-party content into a subdir and don't want git add . to -# swallow it. -# Caller must ensure .git/ exists (run after `git init` or `git clone`). -setup_git_excludes() { - if [[ ! -d .git ]]; then - echo "setup_git_excludes: no .git/ in $(pwd)" >&2 - exit 5 - fi - { - echo '.claude/settings.local.json' - echo '.codex/auth.json' - echo '.codex/env.json' - echo '.codex/config.toml' - echo 'opencode.json' - echo '.pi-agent/' - for extra in "$@"; do - echo "$extra" - done - } >> .git/info/exclude -} - -# commit_initial moved into the launcher (workspace-creator.ts `commitInitial`). -# Every workspace's initial commit is now made uniformly by the launcher after -# context injection — the "Harness rule": fresh git, one clean initial commit. diff --git a/src/workspaces/templates/auto-quant/bootstrap.mjs b/src/workspaces/templates/auto-quant/bootstrap.mjs new file mode 100644 index 000000000..84de68bed --- /dev/null +++ b/src/workspaces/templates/auto-quant/bootstrap.mjs @@ -0,0 +1,102 @@ +/** + * Bootstrap an Auto-Quant workspace — cross-platform Node port of the old + * `bootstrap.sh`. Runs on the Electron-bundled Node (no bash) and the bundled + * git via `_common.mjs` (no system git / Git-for-Windows). + * + * argv: process.argv[2] = tag, process.argv[3] = outDir (absolute) + * env: AQ_TEMPLATE_DIR — optional power-user override pointing at an + * existing Auto-Quant clone. If unset/invalid we + * manage our own mirror clone of + * https://github.com/TraderAlice/Auto-Quant under + * $AQ_LAUNCHER_ROOT/auto-quant-mirror. + * AQ_LAUNCHER_ROOT — optional; defaults to ~/.openalice/workspaces. + * + * Zero-config by default: a fresh install clones the public Auto-Quant repo on + * the first workspace creation; subsequent creations reuse the local mirror via + * `git clone --local` (fast hardlinks; falls back to copy where the filesystem + * doesn't support hardlinks). The launcher makes the initial commit after this + * script. Each workspace owns its own real `user_data/data/` (not a shared + * symlink) — Auto-Quant's data schema may evolve between releases, so a shared + * cache would silently mix incompatible files across generations. + */ + +import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, writeFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { homedir } from 'node:os' +import { setupGitExcludes, git } from '../_common.mjs' + +const tag = process.argv[2] +const outDir = process.argv[3] +if (!tag || !outDir) { + console.error('usage: bootstrap.mjs ') + process.exit(1) +} + +if (existsSync(outDir)) { + console.error(`outDir already exists: ${outDir}`) + process.exit(2) +} + +// ── Resolve Auto-Quant source ─────────────────────────────────────────────── +const AUTO_QUANT_UPSTREAM = 'https://github.com/TraderAlice/Auto-Quant.git' +const launcherRoot = process.env.AQ_LAUNCHER_ROOT || join(homedir(), '.openalice', 'workspaces') +const mirror = join(launcherRoot, 'auto-quant-mirror') + +let source = '' +const override = process.env.AQ_TEMPLATE_DIR +if (override && existsSync(join(override, '.git'))) { + // Power-user override: use the user's pre-existing Auto-Quant clone as-is. + source = override +} else { + // Default path: maintain our own mirror under the launcher root. + if (!existsSync(join(mirror, '.git'))) { + console.error(`[auto-quant] no local mirror at ${mirror}; cloning ${AUTO_QUANT_UPSTREAM} ...`) + mkdirSync(dirname(mirror), { recursive: true }) + await git(['clone', '--quiet', AUTO_QUANT_UPSTREAM, mirror], dirname(mirror)) + } + source = mirror +} + +if (!existsSync(join(source, '.git'))) { + console.error(`[auto-quant] no Auto-Quant source available at ${source}`) + process.exit(3) +} + +// ── Materialise the workspace ─────────────────────────────────────────────── + +// 1. local clone — hardlinks .git/objects, fast and disk-cheap. We clone only +// for the working tree; history + remote are scrubbed below. +await git(['clone', '--local', source, outDir], dirname(outDir)) + +// 2. Scrub to a fresh local repo (no upstream history, no pushable remote), on +// the autoresearch branch. A Harness is always a fresh-git workspace with a +// clean initial commit; carrying Auto-Quant's whole history + an origin +// pointing at the public GitHub repo violates that. The launcher makes the +// initial commit after this script returns. +rmSync(join(outDir, '.git'), { recursive: true, force: true }) +await git(['init', '-q'], outDir) +await git(['checkout', '-b', `autoresearch/${tag}`], outDir) + +// Agent-config excludes — defense-in-depth: keep any later workspace-specific +// AI provider secrets out of a push to upstream Auto-Quant. +setupGitExcludes(outDir) + +// 3. user_data/data is a real per-workspace directory. Auto-Quant's .gitignore +// already excludes user_data/data/, so prepare.py's output is untracked. If +// the SOURCE ships pre-fetched data, copy it in so the user need not re-fetch. +const wsData = join(outDir, 'user_data', 'data') +mkdirSync(wsData, { recursive: true }) +const srcData = join(source, 'user_data', 'data') +if (existsSync(srcData) && readdirSync(srcData).length > 0) { + console.error(`[auto-quant] copying pre-fetched data from ${srcData}`) + cpSync(srcData, wsData, { recursive: true }) +} + +// 4. results.tsv header — the agent appends rows from here on out. +writeFileSync(join(outDir, 'results.tsv'), 'commit\tevent\tstrategy_name\tsharpe\tmax_dd\tnote\n') + +// NOTE: intentionally no copyReadme here — the workspace IS an Auto-Quant clone, +// so its working tree already carries Auto-Quant's own README.md, which is the +// right one for the agent / user opening the folder. + +console.log(`bootstrapped autoresearch/${tag} at ${outDir}`) diff --git a/src/workspaces/templates/auto-quant/bootstrap.sh b/src/workspaces/templates/auto-quant/bootstrap.sh deleted file mode 100755 index 005df5eba..000000000 --- a/src/workspaces/templates/auto-quant/bootstrap.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env bash -# Bootstrap script for Auto-Quant workspaces. -# -# Contract with the launcher (workspace-creator.ts): -# argv: $1 = tag (validated by the launcher: ^[a-z0-9][a-z0-9_-]{0,32}$) -# $2 = outDir (absolute path the launcher wants the workspace at) -# env: AQ_LAUNCHER_ROOT — optional; defaults to ~/.openalice/workspaces -# AQ_TEMPLATE_DIR — optional power-user override pointing at an -# existing Auto-Quant clone. If unset or -# invalid, we manage our own mirror clone of -# https://github.com/TraderAlice/Auto-Quant -# under $AQ_LAUNCHER_ROOT/auto-quant-mirror. -# exit: 0 on success, non-zero on any failure (stderr surfaces to the API caller) -# -# Zero-config by default: a fresh OpenAlice install clones the public -# Auto-Quant repo on the first workspace creation. Subsequent creations -# reuse the local mirror via `git clone --local` (fast, disk-cheap -# hardlinks). To refresh the mirror, `cd $AQ_LAUNCHER_ROOT/auto-quant-mirror -# && git pull`, or `rm -rf` it to force a re-clone. -# -# Workspace isolation: each workspace owns its own `user_data/data/` (real -# directory, not a shared symlink). First-run inside the workspace, the -# user runs `uv run prepare.py` to fetch OHLCV from Binance into that -# workspace's data dir. Other workspaces don't see those bytes — they each -# fetch their own. This is deliberate: Auto-Quant's data schema may evolve -# between releases (different timeframes, different asset sets, different -# .feather column layouts), and a shared cache would silently mix incompat- -# ible files across workspace generations with no clean migration path. -# Disk cost (a few GB per workspace) is the trade. - -set -euo pipefail - -TAG="${1:?tag required}" -OUT_DIR="${2:?outDir required}" - -if [[ -e "$OUT_DIR" ]]; then - echo "outDir already exists: $OUT_DIR" >&2 - exit 2 -fi - -# ── Resolve Auto-Quant source ─────────────────────────────────────────────── - -AUTO_QUANT_UPSTREAM="https://github.com/TraderAlice/Auto-Quant.git" -LAUNCHER_ROOT="${AQ_LAUNCHER_ROOT:-$HOME/.openalice/workspaces}" -MIRROR="$LAUNCHER_ROOT/auto-quant-mirror" - -SOURCE="" -if [[ -n "${AQ_TEMPLATE_DIR:-}" && -d "$AQ_TEMPLATE_DIR/.git" ]]; then - # Power-user override: use the user's pre-existing Auto-Quant clone as-is. - SOURCE="$AQ_TEMPLATE_DIR" -else - # Default path: maintain our own mirror under the launcher root. - if [[ ! -d "$MIRROR/.git" ]]; then - echo "[auto-quant] no local mirror at $MIRROR; cloning $AUTO_QUANT_UPSTREAM ..." >&2 - mkdir -p "$(dirname "$MIRROR")" - git clone --quiet "$AUTO_QUANT_UPSTREAM" "$MIRROR" >&2 - fi - SOURCE="$MIRROR" -fi - -if [[ ! -d "$SOURCE/.git" ]]; then - echo "[auto-quant] no Auto-Quant source available at $SOURCE" >&2 - exit 3 -fi - -# ── Materialise the workspace ─────────────────────────────────────────────── - -# 1. local clone — hardlinks .git/objects, fast and disk-cheap. We clone only -# for the working tree; history + remote are scrubbed below. -git clone --local "$SOURCE" "$OUT_DIR" >/dev/null - -cd "$OUT_DIR" - -# 2. Scrub to a fresh local repo (no upstream history, no pushable remote), on -# the autoresearch branch. A Harness is always a fresh-git workspace with a -# clean initial commit — carrying Auto-Quant's whole history + an origin -# pointing at the public GitHub repo violates that (and was the key-leak -# vector the excludes only half-covered). The launcher makes the initial -# commit after this script returns. -rm -rf .git -git init -q -git checkout -b "autoresearch/$TAG" >/dev/null - -# ── Agent-config excludes ──────────────────────────────────────────────── -# Preemptive defense: if the user later configures workspace-specific AI -# provider via the OpenAlice UI (writing `.claude/settings.local.json` / -# `.codex/auth.json`), the per-clone exclude keeps those secrets out of any -# push to upstream Auto-Quant. Claude itself auto-ignores its file; this -# entry is defense-in-depth. -source "$(dirname "${BASH_SOURCE[0]}")/../_common.sh" -setup_git_excludes - -# 3. user_data/data is a real per-workspace directory. Auto-Quant's -# `.gitignore` already excludes `user_data/data/`, so prepare.py's output -# is untracked. If the SOURCE happens to ship pre-fetched data -# (power-user override pointing at a clone with cached OHLCV), copy it -# in so the user doesn't have to re-fetch. -mkdir -p user_data/data -if [[ -d "$SOURCE/user_data/data" ]]; then - if [[ -n "$(ls -A "$SOURCE/user_data/data" 2>/dev/null)" ]]; then - echo "[auto-quant] copying pre-fetched data from $SOURCE/user_data/data" >&2 - cp -R "$SOURCE/user_data/data/." user_data/data/ - fi -fi - -# 4. results.tsv header — the agent appends rows from here on out. -printf 'commit\tevent\tstrategy_name\tsharpe\tmax_dd\tnote\n' > results.tsv - -# Intentionally NOT calling copy_readme here: the workspace IS an Auto-Quant -# clone, so its working tree already carries Auto-Quant's own README.md, -# which is the right one for the agent / user opening the folder. Our -# template-level README.md lives in templates/auto-quant/README.md and -# powers the showcase page — it isn't meant to land in the instance. - -echo "bootstrapped autoresearch/$TAG at $OUT_DIR" diff --git a/src/workspaces/templates/chat/bootstrap.mjs b/src/workspaces/templates/chat/bootstrap.mjs new file mode 100644 index 000000000..76f05fc69 --- /dev/null +++ b/src/workspaces/templates/chat/bootstrap.mjs @@ -0,0 +1,30 @@ +/** + * Bootstrap a chat workspace: a bare git repo + README, nothing more. + * Cross-platform Node port of the old `bootstrap.sh` — runs on the Electron- + * bundled Node (no bash) and the bundled git via `_common.mjs` (no system git). + * + * Context injection — Alice persona into CLAUDE.md / AGENTS.md plus the per-CLI + * skills — and the initial commit are done by the launcher AFTER this script, + * gated by template.json flags (see context-injector.ts). This script just + * lays down the bare workspace and inits git. + * + * argv: process.argv[2] = tag, process.argv[3] = outDir + * env: AQ_TEMPLATE_ROOT — abs path to this template's root (for README) + */ + +import { initWorkspaceDir, copyReadme, setupGitExcludes, git } from '../_common.mjs' + +const tag = process.argv[2] +const outDir = process.argv[3] +if (!tag || !outDir) { + console.error('usage: bootstrap.mjs ') + process.exit(1) +} + +initWorkspaceDir(outDir) +copyReadme(outDir) + +await git(['init', '-q'], outDir) +setupGitExcludes(outDir) + +console.log(`bootstrapped chat workspace '${tag}' at ${outDir}`) diff --git a/src/workspaces/templates/chat/bootstrap.sh b/src/workspaces/templates/chat/bootstrap.sh deleted file mode 100755 index f05c22fa9..000000000 --- a/src/workspaces/templates/chat/bootstrap.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Bootstrap a chat workspace: a bare git repo + README, nothing more. -# -# Context injection — Alice persona composed into CLAUDE.md / AGENTS.md plus the -# per-CLI skills (alice* / traderhub) — is done by the launcher AFTER this -# script, gated by template.json flags (see context-injector.ts). The -# launcher also makes the initial commit. This script just lays down the bare -# workspace and inits git. -# -# Contract: -# argv: $1 = tag, $2 = outDir -# env: AQ_TEMPLATE_ROOT — abs path to this template's root (for README) -# exit: 0 ok, non-zero on any failure - -set -euo pipefail - -TAG="${1:?tag required}" -OUT_DIR="${2:?outDir required}" - -source "$(dirname "${BASH_SOURCE[0]}")/../_common.sh" - -init_workspace_dir "$OUT_DIR" -copy_readme - -git init -q -setup_git_excludes - -echo "bootstrapped chat workspace '$TAG' at $OUT_DIR" diff --git a/src/workspaces/workspace-creation.e2e.spec.ts b/src/workspaces/workspace-creation.e2e.spec.ts index 31f334ee3..6c9120740 100644 --- a/src/workspaces/workspace-creation.e2e.spec.ts +++ b/src/workspaces/workspace-creation.e2e.spec.ts @@ -1,9 +1,9 @@ /** - * End-to-end check of the create flow for the `chat` template, exercising the - * real moving parts in order: the (shrunk) bootstrap.sh → launcher context - * injection → launcher initial commit. Proves the load-bearing Phase-B change - * — commit authority moved from bootstrap.sh into the launcher — yields a - * fresh-git workspace with exactly one clean commit (the "Harness rule"). + * End-to-end check of the create flow, exercising the real moving parts in + * order: bootstrap.mjs (run on the bundled Node + dugite's bundled git) → + * launcher context injection → launcher initial commit. Proves the workspace + * is a fresh-git repo with exactly one clean commit (the "Harness rule"), and + * — via the PATH-stripped case — that creation needs NO system git or bash. */ import { spawn } from 'node:child_process'; @@ -22,9 +22,26 @@ import { commitInitial } from './workspace-creator.js'; const HERE = fileURLToPath(new URL('.', import.meta.url)); // src/workspaces/ const CHAT_DIR = join(HERE, 'templates', 'chat'); const CHAT_FILES = join(CHAT_DIR, 'files'); -const CHAT_BOOTSTRAP = join(CHAT_DIR, 'bootstrap.sh'); +const CHAT_BOOTSTRAP = join(CHAT_DIR, 'bootstrap.mjs'); const AQ_DIR = join(HERE, 'templates', 'auto-quant'); -const AQ_BOOTSTRAP = join(AQ_DIR, 'bootstrap.sh'); +const AQ_BOOTSTRAP = join(AQ_DIR, 'bootstrap.mjs'); + +/** + * Run a bootstrap.mjs exactly as the launcher's runScript does: on the bundled + * Node (`process.execPath`) with ELECTRON_RUN_AS_NODE. `strip` removes git/bash + * from PATH to prove the bare-machine path uses only dugite's embedded git. + */ +function runBootstrap( + script: string, + args: readonly string[], + extraEnv: NodeJS.ProcessEnv, + strip = false, +): Promise { + const env = strip + ? { HOME: process.env.HOME, ELECTRON_RUN_AS_NODE: '1', PATH: '', ...extraEnv } + : { ...process.env, ELECTRON_RUN_AS_NODE: '1', ...extraEnv }; + return run(process.execPath, [script, ...args], env); +} function autoQuantMeta(): TemplateMeta { return { @@ -78,8 +95,10 @@ afterEach(async () => { describe('chat workspace create: bootstrap → inject → commit', () => { it('yields a fresh-git workspace with one clean launcher commit', async () => { - // 1. real bootstrap.sh — git init + README + excludes, NO commit - await run('bash', [CHAT_BOOTSTRAP, 'testtag', dir], { ...process.env, AQ_TEMPLATE_ROOT: CHAT_DIR }); + // 1. real bootstrap.mjs — git init + README + excludes, NO commit. PATH + // stripped: proves a bare machine (no system git, no bash) still works + // via dugite's bundled git. + await runBootstrap(CHAT_BOOTSTRAP, ['testtag', dir], { AQ_TEMPLATE_ROOT: CHAT_DIR }, true); // 2. launcher-owned injection await injectWorkspaceContext({ template: chatMeta(), wsId: 'ws-e2e-1', dir }); // 3. launcher-owned initial commit @@ -126,7 +145,7 @@ describe('auto-quant workspace create: clone → scrub → commit', () => { await run('git', ['-C', src, 'remote', 'add', 'origin', 'https://github.com/TraderAlice/Auto-Quant.git']); const aqDir = join(parent, 'aq-workspace'); - await run('bash', [AQ_BOOTSTRAP, 'aqtag', aqDir], { ...process.env, AQ_TEMPLATE_DIR: src }); + await runBootstrap(AQ_BOOTSTRAP, ['aqtag', aqDir], { AQ_TEMPLATE_DIR: src }); // auto-quant injects nothing (all flags false); launcher still commits. await injectWorkspaceContext({ template: autoQuantMeta(), wsId: 'ws-aq-1', dir: aqDir }); await commitInitial(aqDir, 'auto-quant: aqtag'); @@ -144,7 +163,7 @@ describe('auto-quant workspace create: clone → scrub → commit', () => { describe('chat workspace create — CLI-only injection (no MCP)', () => { it('injects the per-CLI alice*/traderhub skills and writes no MCP files', async () => { - await run('bash', [CHAT_BOOTSTRAP, 'clitag', dir], { ...process.env, AQ_TEMPLATE_ROOT: CHAT_DIR }); + await runBootstrap(CHAT_BOOTSTRAP, ['clitag', dir], { AQ_TEMPLATE_ROOT: CHAT_DIR }); await injectWorkspaceContext({ template: chatMeta(), wsId: 'ws-cli-1', dir }); await commitInitial(dir, 'chat: clitag'); diff --git a/src/workspaces/workspace-creator.spec.ts b/src/workspaces/workspace-creator.spec.ts index d61f71663..576626f2b 100644 --- a/src/workspaces/workspace-creator.spec.ts +++ b/src/workspaces/workspace-creator.spec.ts @@ -124,6 +124,47 @@ describe('runScript platform branching', () => { ); }); + it('a .mjs bootstrap runs on the bundled Node (process.execPath), NOT bash, on win32', async () => { + setPlatform('win32'); + const child = makeFakeChild(); + mockSpawn.mockReturnValue(child as unknown as childProcess.ChildProcess); + + const promise = runScript( + 'C:\\Users\\me\\templates\\chat\\bootstrap.mjs', + ['tag-1', 'C:\\out'], + { FOO: 'bar' }, + 60_000, + ); + child.emit('close', 0); + const res = await promise; + + expect(res.ok).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + ['C:\\Users\\me\\templates\\chat\\bootstrap.mjs', 'tag-1', 'C:\\out'], + expect.objectContaining({ + env: expect.objectContaining({ FOO: 'bar', ELECTRON_RUN_AS_NODE: '1' }), + }), + ); + }); + + it('a .mjs bootstrap runs on process.execpath on macOS too (no shebang/bash reliance)', async () => { + setPlatform('darwin'); + const child = makeFakeChild(); + mockSpawn.mockReturnValue(child as unknown as childProcess.ChildProcess); + + const promise = runScript('/tmp/foo/bootstrap.mjs', ['t', '/out'], {}, 60_000); + child.emit('close', 0); + const res = await promise; + + expect(res.ok).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + ['/tmp/foo/bootstrap.mjs', 't', '/out'], + expect.objectContaining({ env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' }) }), + ); + }); + it('on win32, ENOENT spawn error surfaces a Git-for-Windows install hint', async () => { setPlatform('win32'); const child = makeFakeChild(); diff --git a/src/workspaces/workspace-creator.ts b/src/workspaces/workspace-creator.ts index c1da6aa12..07ec17058 100644 --- a/src/workspaces/workspace-creator.ts +++ b/src/workspaces/workspace-creator.ts @@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto'; import { rm } from 'node:fs/promises'; import { join } from 'node:path'; +import { exec as gitExec } from 'dugite'; + import { readCredentials } from '@/core/config.js'; import type { AdapterRegistry } from './cli-adapter.js'; @@ -270,17 +272,16 @@ export async function commitInitial(dir: string, message: string): Promise ]); } -function runGit(dir: string, args: readonly string[]): Promise { - return new Promise((resolveCommit, reject) => { - const child = spawn('git', [...args], { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'] }); - let stderr = ''; - child.stderr?.on('data', (c: Buffer) => { stderr += c.toString(); }); - child.on('error', reject); - child.on('close', (code) => { - if (code === 0) resolveCommit(); - else reject(new Error(`git ${args[0] ?? ''} exited ${code ?? 'null'}: ${stderr.slice(0, 500)}`)); - }); - }); +// Routes through the bundled git (dugite) so the launcher's initial commit +// needs no system git — same reason the bootstrap scripts use _common.mjs's +// git(). dugite resolves with an exitCode (it only rejects when git fails to +// launch), so a non-zero exit is turned into a throw to preserve the old +// reject-on-failure contract. +async function runGit(dir: string, args: readonly string[]): Promise { + const r = await gitExec([...args], dir); + if (r.exitCode !== 0) { + throw new Error(`git ${args[0] ?? ''} exited ${r.exitCode}: ${String(r.stderr).slice(0, 500)}`); + } } interface RunResult { @@ -291,9 +292,10 @@ interface RunResult { } const WINDOWS_BASH_HINT = - 'hint: bash not found on PATH. Install Git for Windows (accept the default ' + - '"Use Git from the Windows Command Prompt" option) from https://gitforwindows.org/, ' + - 'or run OpenAlice from inside WSL2.'; + 'hint: this template ships a bash bootstrap script. OpenAlice\'s built-in ' + + 'templates (chat, auto-quant) need no bash — only third-party templates do. ' + + 'To use this one, install Git for Windows from https://gitforwindows.org/ so ' + + 'bash is on PATH, or run OpenAlice from inside WSL2.'; /** * Run a bootstrap script. @@ -314,13 +316,25 @@ export function runScript( extraEnv: { [key: string]: string }, timeoutMs: number, ): Promise { + const isMjs = script.endsWith('.mjs'); const isWindows = process.platform === 'win32'; - const cmd = isWindows ? 'bash' : script; - const cmdArgs = isWindows ? [script, ...args] : args; + + // `.mjs` (built-in templates): run on the Electron-bundled Node. In the + // packaged app `process.execPath` is the Electron binary; ELECTRON_RUN_AS_NODE + // flips it to pure-Node mode (a harmless no-op for a plain `node` execPath in + // dev). No bash, no shebang reliance → works on a bare Windows/Mac box. + // `.sh` (third-party fallback): unix reads the `#!/usr/bin/env bash` shebang; + // Windows has no native bash, so we invoke `bash