Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,29 @@ jobs:
name: amicode-vsix
path: packages/extension/amicode.vsix
if-no-files-found: error
app-shelf-boot-proof:
# #822 the env-gated LIVE boot proof lane (the telaio_app_probe precedent).
# HONEST STUB: ci.yml does NOT build the app dist in this slice (the full
# bun recipe — upstream tarball fetch + ~4,700-package install — is the
# packaging chore issue #825, which also wires build:app into the release
# pipeline), so the probe SKIPS here with the reason
# printed. It runs FOR REAL once a dist is present: pass AMICODE_APP_DIST
# pointing at packages/extension/dist/app after a build:app step. The
# contract itself is held by the vitest suite (mock engine + mock dist);
# this lane is the end-to-end proof, never a false green — a skip is
# printed as a skip.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm --filter amicode fetch:opencode
env:
GH_TOKEN: ${{ secrets.OPENCODE_FETCH_TOKEN }} # the probe's engine default is the vendored binary
- name: boot proof — real service + real engine + real dist (self-skips until the dist-build chore lands)
run: AMICODE_APP_DIST=${AMICODE_APP_DIST:-} node packages/extension/scripts/amicode_service_boot_probe.mjs
boot-smoke:
strategy:
matrix:
Expand Down
6 changes: 6 additions & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@
"default": "",
"description": "Override the extension's resource root for scores, templates, and bin/ (dev only). Empty = use the installed extension path. Set via the Developer Tools section in settings."
},
"amicode.appBundleDir": {
"type": "string",
"default": "",
"description": "Path to a built app-bundle dist (dev override). The amicode service serves it as the app UI on its origin. Empty = the packaged dist (dist/app inside the extension, staged by `pnpm --filter amicode run build:app`). When neither exists the service serves an honest needs-setup placeholder — the /amicode/* API surface is unaffected."
},
"amicode.opencodePort": {
"type": "number",
"default": 43117,
Expand Down Expand Up @@ -380,6 +385,7 @@
"opencode:build": "node scripts/opencode_dev.mjs build",
"opencode:pin": "node scripts/opencode_dev.mjs pin",
"build:exemplars": "node scripts/build_exemplars.mjs",
"build:app": "node scripts/build_app_bundle.mjs",
"healthcheck": "node scripts/healthcheck.mjs",
"package": "pnpm --filter @amicode/amico-run build && pnpm run build && pnpm run build:exemplars && pnpm run fetch:opencode && vsce package --no-dependencies --allow-missing-repository -o amicode.vsix",
"dev:pulseplot": "esbuild dev/pulseplot_harness/main.ts --bundle --format=iife --outfile=dev/pulseplot_harness/main.js && open dev/pulseplot_harness/index.html",
Expand Down
73 changes: 73 additions & 0 deletions packages/extension/scripts/amicode_service_boot_probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env node
// amicode_service_boot_probe.mjs — the #822 env-gated LIVE boot proof
// (the telaio_app_probe.mjs convention).
//
// Boots the REAL amicode service (bundled from this repo's own source — no
// transcribed logic, no drift) against a REAL spawned engine (the vendored
// opencode binary, password-armed) and a REAL built app dist, and asserts
// end-to-end from the service origin: the app document, an engine API call
// through the proxy, an SSE connect through the proxy, and one /amicode/*
// route — all with the ENGINE credential (the framed app's bootstrap, the
// zero-app-side-change contract).
//
// ENV-GATED (CI never builds the dist in this slice — the packaging-chore
// issue is the follow-up): with AMICODE_APP_DIST absent (or without an
// index.html) the probe SKIPS (exit 0) and says why. AMICODE_ENGINE_BIN
// defaults to the vendored binary (fetch:opencode); absent → skip.
//
// USAGE (the live phase / a dev machine):
// node packages/extension/scripts/build_app_bundle.mjs # stages dist/app
// pnpm --filter amicode fetch:opencode # vendors the engine
// AMICODE_APP_DIST=packages/extension/dist/app \
// node packages/extension/scripts/amicode_service_boot_probe.mjs
//
// READ-ONLY EVIDENCE: boots, probes, reports, stops — never mutates state.
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";

const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");

const skip = (msg) => {
console.log(`[boot-probe] SKIP: ${msg}`);
process.exit(0);
};

const APP_DIST = (process.env.AMICODE_APP_DIST ?? "").trim();
if (!APP_DIST) skip("AMICODE_APP_DIST not set — the env-gated live-phase script (CI has no built dist in this slice)");
if (!existsSync(join(APP_DIST, "index.html")))
skip(`AMICODE_APP_DIST has no index.html (${APP_DIST}) — run \`pnpm --filter amicode run build:app\` first`);

const ENGINE_BIN = (process.env.AMICODE_ENGINE_BIN ?? "").trim() || join(PKG_ROOT, "vendor", "opencode", `${process.platform}-${process.arch}`, "opencode");
if (!existsSync(ENGINE_BIN)) skip(`no engine binary at ${ENGINE_BIN} — run \`pnpm --filter amicode fetch:opencode\` or set AMICODE_ENGINE_BIN`);

// Bundle the REAL service + probe entry from source (the same esbuild the
// extension build uses) into a throwaway ESM file, then run it. Bundling —
// not transpiling-by-hand — is the no-drift rule: the probe runs this repo's
// actual createAmicodeService, shelf, and proxy code.
const outDir = mkdtempSync(join(tmpdir(), "amicode-boot-probe-"));
const entry = join(PKG_ROOT, "src", "amicode_service_boot_probe.ts");
const outfile = join(outDir, "probe.mjs");
try {
await build({
entryPoints: [entry],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
outfile,
sourcemap: false,
minify: false,
logLevel: "warning",
});
const r = spawnSync(process.execPath, [outfile], {
stdio: "inherit",
env: { ...process.env, AMICODE_APP_DIST: APP_DIST, AMICODE_ENGINE_BIN: ENGINE_BIN },
});
process.exit(r.status ?? 1);
} finally {
rmSync(outDir, { recursive: true, force: true });
}
100 changes: 100 additions & 0 deletions packages/extension/scripts/build_app_bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env node
// build_app_bundle.mjs — stage the built app-bundle dist into the extension
// (#822, the fetch:opencode precedent for a build product riding the VSIX).
// The app-bundle README's proven recipe: materialize (canonical base +
// overlay) → bun install → build the app (vite production build) → copy the
// app dist into <extension>/dist/app — the path resolveAppDistRoot's DEFAULT
// expects, which the amicode service's shelf serves at its origin.
//
// USAGE
// node scripts/build_app_bundle.mjs # full recipe
// node scripts/build_app_bundle.mjs --dist <dir> # stage an already-built dist
// node scripts/build_app_bundle.mjs --work <dir> # materialize/reuse this tree
// AMICODE_APP_BUNDLE_WORK=<dir> # --work via env
//
// FAILS LOUDLY, never a silent skip: a packaging step that no-ops is the
// "silently no-op'd fetch" trap the vsix-gate exists to catch. The RUNTIME half
// is honest independently of this script: with no dist staged, the shelf
// serves the needs-setup placeholder (never a silent 404-as-app).
//
// CI NOTE (honest stub): the full bun build (≈4,700 packages + upstream
// tarball fetch) is NOT wired into ci.yml in this slice — build:app is a
// manual/release-pipeline hook until the packaging-chore issue lands. The
// app-shelf-boot-proof CI lane runs the env-gated probe, which skips with the
// reason printed until a dist is built there.
import { spawnSync } from "node:child_process";
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const EXT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const REPO_ROOT = join(EXT_ROOT, "..", "..");
const BUNDLE_PKG = join(REPO_ROOT, "packages", "app-bundle");

const args = process.argv.slice(2);
const flag = (n) => {
const i = args.indexOf(`--${n}`);
return i >= 0 ? args[i + 1] : undefined;
};

const fail = (msg, code = 1) => {
console.error(`[build:app] FAIL: ${msg}`);
process.exit(code);
};

const run = (cmd, cmdArgs, cwd, note) => {
console.log(`[build:app] ${note}: ${cmd} ${cmdArgs.join(" ")} (cwd=${cwd})`);
const r = spawnSync(cmd, cmdArgs, { cwd, stdio: "inherit" });
if (r.status !== 0) fail(`${note} failed (exit ${r.status})`, 2);
};

const stageDist = (distDir) => {
if (!existsSync(join(distDir, "index.html")))
fail(`no dist to stage: ${distDir} has no index.html`);
const target = join(EXT_ROOT, "dist", "app");
rmSync(target, { recursive: true, force: true });
mkdirSync(join(target, ".."), { recursive: true });
cpSync(distDir, target, { recursive: true });
if (!existsSync(join(target, "index.html"))) fail(`staging ${distDir} → ${target} lost the index document`);
const files = readdirSync(target);
console.log(`[build:app] staged ${files.length} top-level entries → packages/extension/dist/app`);
console.log("[build:app] DONE — the amicode service's shelf serves this at its origin");
};

// ── stage-only mode: an already-built dist (the telaio probe's recipe) ───────
const prebuilt = flag("dist");
if (prebuilt) {
stageDist(prebuilt);
process.exit(0);
}

// ── the full recipe ──────────────────────────────────────────────────────────
const work = flag("work") ?? process.env.AMICODE_APP_BUNDLE_WORK ?? join(BUNDLE_PKG, ".materialized");

if (!existsSync(join(work, "package.json"))) {
run("node", [join(BUNDLE_PKG, "scripts", "materialize.mjs"), "--out", work], REPO_ROOT, "materialize (canonical base + overlay)");
}
if (!existsSync(join(work, "packages", "app")))
fail(`${work} has no packages/app — not a materialized app tree (pass --work to point at one)`);

const bun = spawnSync("which", ["bun"], { encoding: "utf8" });
if (bun.status !== 0 || !bun.stdout.trim())
fail("bun is not on PATH — the app-bundle README's recipe installs with bun (https://bun.sh)");
run("bun", ["install"], work, "bun install (the app tree's ~4,700 packages)");
// bun, not pnpm, and cwd-scoped: the materialized tree pins
// `"packageManager": "bun@…"` (corepack-managed pnpm refuses to run scripts
// in it) and bun's --filter finds no packages (the tree's workspace layout);
// running the app package's OWN `vite build` script in its directory just
// works — the README's proven 14s build.
run("bun", ["run", "build"], join(work, "packages", "app"), "app build (vite production build)");

const built = join(work, "packages", "app", "dist");
if (!existsSync(join(built, "index.html"))) {
const appDir = join(work, "packages", "app");
const candidates = existsSync(appDir) ? readdirSync(appDir).filter((e) => existsSync(join(appDir, e, "index.html"))) : [];
fail(
`no built dist at ${built} (packages/app build output with index.html). ` +
(candidates.length > 0 ? `Found index.html in: ${candidates.join(", ")}` : "No index.html anywhere under packages/app — the build did not emit the app document."),
);
}
stageDist(built);
Loading
Loading