#!/usr/bin/env node // Runs on the HOST before the container starts. // Validates prerequisites and sets up files that the container needs // without using ../ bind mounts (which break on newer Docker runtimes). // // This is Node.js (not bash) so it works on Windows without WSL. import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; // The devcontainer CLI runs initializeCommand from the workspace folder (explore/). // Use CWD, not __dirname, so this works both in production and in tests. if (!fs.existsSync('../.env')) { console.error(` ❌ Missing .env file. Create it first: Create a file named .env in the toolkit root with: ANTHROPIC_API_KEY=your-key-here ANTHROPIC_BASE_URL=https://app-llmproxy.dataannotation.tech/api/llm_proxy/raccoon `); process.exit(1); } // Copy small files from toolkit root into explore/ so the container // can access them without ../ bind mounts. fs.copyFileSync('../.env', '.env'); try { fs.copyFileSync('../toolkit.json', 'toolkit.json'); } catch {} // Link repo so the bind mount source stays within explore/. // Use a junction on Windows (Docker Desktop can't follow symlinks, // but it can follow junctions). On macOS/Linux, 'junction' is ignored // and creates a regular symlink. // Single-repo toolkits have ../repo; polyglot toolkits have ../repos (the member // clones) instead. Link whichever exists so the matching bind mount resolves. if (fs.existsSync('../repo') && !fs.existsSync('repo')) { fs.symlinkSync(path.resolve('../repo'), 'repo', 'junction'); } if (fs.existsSync('../repos') && !fs.existsSync('repos')) { fs.symlinkSync(path.resolve('../repos'), 'repos', 'junction'); } // Reference-data corpus lives at the toolkit root (../data, zeta toolkits only). // Link it under explore/ so the corpus bind mount's source stays within explore/ // (../ bind mounts break on newer Docker runtimes; a resolved symlink/junction works, // exactly as for repo/repos above). Only present when this toolkit ships a corpus. if (fs.existsSync('../data') && !fs.existsSync('data')) { fs.symlinkSync(path.resolve('../data'), 'data', 'junction'); } // A named extra instance (EXPLORE_INSTANCE set, normally by instance.js) gets // its OWN repo working tree, mounted at /workspace/repo in that container, so a // `git checkout` in one instance doesn't disturb another. A `git clone --local` // hardlinks the object store, so this is cheap and fully self-contained — unlike // a git worktree, whose gitdir lives inside the source repo and so wouldn't // bind-mount into the container. The devcontainer.json mount derives the dir // name from EXPLORE_INSTANCE (repo); create it before that mount binds. // post-create.sh then checks out the default commit + runs setup in the new // container, exactly as it does for the primary repo. const instance = process.env.EXPLORE_INSTANCE || ''; if (instance) { try { if (fs.existsSync('../repos')) { // Polyglot toolkit: give the instance its OWN copy of every member repo at // repos/, mounted at /workspace/repos. A git clone --local // hardlinks each member's object store, so this is cheap and fully isolated — // a member checkout in one instance never disturbs another. const dir = `repos${instance}`; if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); for (const member of fs.readdirSync(path.resolve('../repos'))) { const src = path.resolve('../repos', member); if (!fs.statSync(src).isDirectory()) continue; execSync( `git clone --local ${JSON.stringify(src)} ${JSON.stringify(path.join(dir, member))}`, { stdio: 'inherit', } ); } } } else { // Single-repo toolkit: clone repo → repo, mounted at /workspace/repo. const dir = `repo${instance}`; if (!fs.existsSync(dir)) { execSync( `git clone --local ${JSON.stringify(path.resolve('../repo'))} ${JSON.stringify(dir)}`, { stdio: 'inherit', } ); } } } catch { console.error( `\n❌ Couldn't create the repo working tree for instance "${instance}".\n` + ` This needs git on your PATH. Install git, then retry.\n` ); process.exit(1); } } // Best-effort: warn if an existing container for this folder doesn't publish // the app ports. Docker fixes -p mappings when a container is CREATED, so a // container built by an older toolkit (before/with different appPort) keeps its // old mappings even when you re-run `up`. The only way to pick up new ports is // to recreate the container — so we point that out here rather than letting the // worker stare at a dead localhost. Wrapped so it can never block startup: any // failure (docker missing, odd output) is swallowed and the check is skipped. // // Skipped for named instances: they're managed by instance.js (their own ports, // and they carry an id-label instead of this folder's local_folder label), so // this folder-scoped check would only ever inspect the primary container. if (!instance) try { // Container ports we expect published. The browsable port is 3000 for every // repo; Palolo also serves its API on 3001; zeta toolkits serve the corpus // viewer on 3002. Read from toolkit.json when available, else assume the base pair. let expected = [3000, 3001]; try { const tk = JSON.parse(fs.readFileSync('toolkit.json', 'utf-8')); expected = tk.explorePorts && tk.explorePorts.serverHost ? [3000, 3001] : [3000]; if (tk.explorePorts && tk.explorePorts.corpusHost) expected.push(3002); } catch {} const folder = process.cwd(); const ids = execSync(`docker ps -aq --filter "label=devcontainer.local_folder=${folder}"`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], }) .trim() .split('\n') .filter(Boolean); for (const id of ids) { const bindings = execSync( `docker inspect --format "{{json .HostConfig.PortBindings}}" ${id}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], } ).trim(); const missing = expected.filter((p) => !bindings.includes(`${p}/tcp`)); if (missing.length > 0) { console.error(` ⚠️ An existing container for this folder doesn't publish port(s) ${missing.join(', ')}. Docker fixes port mappings when a container is created, so re-running 'up' alone won't add them. To expose the app, recreate the container: npx @devcontainers/cli up --remove-existing-container Note: recreating wipes the container's Claude history — run /create-snapshot first if there's a conversation you want to keep. `); break; } } } catch { // docker unavailable or unexpected output — skip the check. }