196 lines
7.4 KiB
JavaScript
196 lines
7.4 KiB
JavaScript
#!/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<instance>); 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<instance>/<member>, 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<instance>, 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.
|
|
}
|