#!/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=the-base-url-you-were-given `); 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. // Reference-data corpus lives at ../data (zeta toolkits only). // Remove a link WITHOUT following it: unlink covers POSIX symlinks, rmdir covers // Windows junctions (which reject unlink). Never recursive — the target is real data. function removeLink(name) { try { fs.unlinkSync(name); } catch { fs.rmdirSync(name); } } // Replace a stale entry (a link to a path that no longer exists, an empty dir) rather // than skipping — skipping left the bind mount resolving to nothing, unfixably. function linkSibling(name) { const target = path.resolve('..', name); if (!fs.existsSync(target)) return; let current = null; try { current = fs.lstatSync(name); } catch {} if (current) { if (current.isSymbolicLink()) { if (fs.existsSync(name) && fs.realpathSync(name) === fs.realpathSync(target)) return; removeLink(name); } else if (current.isDirectory()) { if (fs.readdirSync(name).length > 0) { console.error(`⚠️ explore/${name} is a non-empty directory, so it was left as is.`); console.error( ` Expected a link to the toolkit root's ${name}/. Remove it and re-run 'up'.` ); return; } fs.rmdirSync(name); } else { return; } } fs.symlinkSync(target, name, 'junction'); } linkSibling('repo'); linkSibling('repos'); linkSibling('data'); // 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. }