From a15d8e7a3d12fe41b934b711bd26a291334607ac Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 16 Jul 2026 01:05:34 -0400 Subject: [PATCH 1/2] feat(julia): managed-setup core module + tests (#8, layer 1) julia_setup.ts: parse Manifest minor, detect juliaup + channel, gate (shouldOfferJuliaSetup), resolveChannelJulia (channel binary for the --julia plumbing, no global-default clobber), buildSetupSteps (consented juliaup install -> juliaup add -> instantiate). 13 unit tests. --- .../extension/src/substrate/julia_setup.ts | 139 ++++++++++++++++++ .../test/substrate/julia_setup.test.ts | 129 ++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 packages/extension/src/substrate/julia_setup.ts create mode 100644 packages/extension/test/substrate/julia_setup.test.ts diff --git a/packages/extension/src/substrate/julia_setup.ts b/packages/extension/src/substrate/julia_setup.ts new file mode 100644 index 000000000..478672c1a --- /dev/null +++ b/packages/extension/src/substrate/julia_setup.ts @@ -0,0 +1,139 @@ +// Managed Julia setup (#8): amicode owns the Julia it runs, via a juliaup +// channel pinned to the Manifest's MINOR (e.g. 1.12). The `1.12` channel tracks +// the latest 1.12 patch (1.12.6 as of writing) — a patch drift from the +// Manifest's pinned 1.12.3, which is fine: install.sh's long-standing policy is +// "minor must match, patch re-resolves." We never touch the user's global +// juliaup default — solves run through the channel's own binary, resolved here +// and handed to amico-run via its existing `--julia` plumbing. +// +// Pure helpers + command builders are unit-testable; the impure shell probes +// take an injectable runner (the healthcheck pattern). +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** Shape of a command to run in a visible integrated-terminal Task (the consent + * surface): the user sees exactly what executes. */ +export interface JuliaSetupStep { + label: string; + /** A shell-ready command line (steps may pipe, e.g. the juliaup installer). */ + command: string; +} + +/** Default absolute Julia project dir (matches resolveJuliaProject's default). */ +export function defaultJuliaProject(): string { + return path.join(os.homedir(), ".amico", "julia"); +} + +/** Parse the pinned Julia MINOR (e.g. "1.12") from a Manifest.toml's + * `julia_version = "1.12.3"`. Returns null if unreadable/unparseable. */ +export function pinnedJuliaMinor(manifestPath: string): string | null { + try { + const txt = fs.readFileSync(manifestPath, "utf8"); + const m = txt.match(/^julia_version\s*=\s*"(\d+)\.(\d+)\.\d+"/m); + return m ? `${m[1]}.${m[2]}` : null; + } catch { + return null; + } +} + +/** Injectable command runner: returns stdout (trimmed), throws on non-zero. */ +export type Runner = (cmd: string, args: string[]) => string; +const defaultRunner: Runner = (cmd, args) => + execFileSync(cmd, args, { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "ignore"] }).trim(); + +/** juliaup present on PATH? */ +export function hasJuliaup(run: Runner = defaultRunner): boolean { + try { + run("juliaup", ["--version"]); + return true; + } catch { + return false; + } +} + +/** Is the `` channel installed + usable? Probes the channel shim rather + * than parsing `juliaup status` (whose table format is not stable across + * versions). `julia +1.12 --version` exits 0 only if the channel resolves. */ +export function hasChannel(minor: string, run: Runner = defaultRunner): boolean { + try { + run("julia", [`+${minor}`, "--startup-file=no", "--version"]); + return true; + } catch { + return false; + } +} + +/** Resolve the channel's concrete julia binary (absolute), to hand to amico-run + * as `--julia` so solves use amicode's pinned Julia regardless of the user's + * global default. Returns null if the channel can't be resolved. */ +export function resolveChannelJulia(minor: string, run: Runner = defaultRunner): string | null { + try { + const bindir = run("julia", [`+${minor}`, "--startup-file=no", "-e", "print(Sys.BINDIR)"]); + if (!bindir) return null; + const exe = process.platform === "win32" ? "julia.exe" : "julia"; + const p = path.join(bindir, exe); + return fs.existsSync(p) ? p : null; + } catch { + return null; + } +} + +/** Has the pinned project been instantiated? Cheap gate check (existence of the + * copied Manifest); full "using Piccolo loads" verification stays in + * healthcheck. The setup Task re-runs instantiate idempotently regardless. */ +export function projectInstantiated(project: string = defaultJuliaProject()): boolean { + return fs.existsSync(path.join(project, "Manifest.toml")); +} + +/** First-run gate (mirrors shouldOfferVaultSetup): offer setup if anything in + * the chain is missing and the user hasn't dismissed. */ +export function shouldOfferJuliaSetup(o: { + juliaupPresent: boolean; + channelPresent: boolean; + projectInstantiated: boolean; + dismissed: boolean; +}): boolean { + return !o.dismissed && (!o.juliaupPresent || !o.channelPresent || !o.projectInstantiated); +} + +/** The juliaup installer one-liner. Unix (linux-x64 + darwin-arm64, the vsix + * targets); `--yes` makes it non-interactive so it runs unattended in the Task + * after the user's explicit consent-click. */ +export function juliaupInstallCommand(): string { + return "curl -fsSL https://install.julialang.org | sh -s -- --yes"; +} + +/** Build the ordered, consented setup steps for the given state. Only the steps + * that are actually needed are emitted (idempotent + minimal). `project` is the + * Julia project dir; `manifestSrc`/`projectSrc` are the bundled pins to seed. */ +export function buildSetupSteps(o: { + minor: string; + juliaupPresent: boolean; + channelPresent: boolean; + project: string; + projectSrc: string; + manifestSrc: string; +}): JuliaSetupStep[] { + const steps: JuliaSetupStep[] = []; + if (!o.juliaupPresent) { + steps.push({ label: "Install juliaup", command: juliaupInstallCommand() }); + } + if (!o.channelPresent) { + // works whether juliaup was just installed (its shim is on PATH via the + // installer's profile edit; the Task re-sources) or already present. + steps.push({ label: `Add Julia ${o.minor}`, command: `juliaup add ${o.minor}` }); + } + // Seed the pinned project, then instantiate through the channel. Quote paths + // for spaces. Instantiate is idempotent + safe to re-run. + steps.push({ + label: "Instantiate Piccolo project", + command: + `mkdir -p "${o.project}" && ` + + `cp "${o.projectSrc}" "${o.project}/Project.toml" && ` + + `cp "${o.manifestSrc}" "${o.project}/Manifest.toml" && ` + + `julia +${o.minor} --project="${o.project}" -e 'using Pkg; Pkg.instantiate()'`, + }); + return steps; +} diff --git a/packages/extension/test/substrate/julia_setup.test.ts b/packages/extension/test/substrate/julia_setup.test.ts new file mode 100644 index 000000000..63df24326 --- /dev/null +++ b/packages/extension/test/substrate/julia_setup.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + pinnedJuliaMinor, + shouldOfferJuliaSetup, + hasJuliaup, + hasChannel, + resolveChannelJulia, + projectInstantiated, + buildSetupSteps, + juliaupInstallCommand, + type Runner, +} from "../../src/substrate/julia_setup"; + +const tmp = () => mkdtempSync(join(tmpdir(), "amicode-julia-")); + +describe("pinnedJuliaMinor", () => { + it("parses the minor from a Manifest julia_version", () => { + const d = tmp(); + writeFileSync(join(d, "Manifest.toml"), 'julia_version = "1.12.3"\nmanifest_format = "2.0"\n'); + expect(pinnedJuliaMinor(join(d, "Manifest.toml"))).toBe("1.12"); + rmSync(d, { recursive: true, force: true }); + }); + it("returns null on missing file or absent key", () => { + const d = tmp(); + expect(pinnedJuliaMinor(join(d, "nope.toml"))).toBeNull(); + writeFileSync(join(d, "Manifest.toml"), 'manifest_format = "2.0"\n'); + expect(pinnedJuliaMinor(join(d, "Manifest.toml"))).toBeNull(); + rmSync(d, { recursive: true, force: true }); + }); +}); + +describe("shouldOfferJuliaSetup", () => { + const ok = { juliaupPresent: true, channelPresent: true, projectInstantiated: true, dismissed: false }; + it("does not offer when the whole chain is present", () => { + expect(shouldOfferJuliaSetup(ok)).toBe(false); + }); + it("offers when any link is missing", () => { + expect(shouldOfferJuliaSetup({ ...ok, juliaupPresent: false })).toBe(true); + expect(shouldOfferJuliaSetup({ ...ok, channelPresent: false })).toBe(true); + expect(shouldOfferJuliaSetup({ ...ok, projectInstantiated: false })).toBe(true); + }); + it("never offers once dismissed, even if incomplete", () => { + expect(shouldOfferJuliaSetup({ juliaupPresent: false, channelPresent: false, projectInstantiated: false, dismissed: true })).toBe(false); + }); +}); + +describe("shell probes (injected runner)", () => { + const okRun: Runner = () => "ok"; + const throwRun: Runner = () => { + throw new Error("not found"); + }; + + it("hasJuliaup reflects the runner", () => { + expect(hasJuliaup(okRun)).toBe(true); + expect(hasJuliaup(throwRun)).toBe(false); + }); + + it("hasChannel probes the channel shim with +minor", () => { + const seen: string[][] = []; + const run: Runner = (_c, a) => { + seen.push(a); + return "julia version 1.12.6"; + }; + expect(hasChannel("1.12", run)).toBe(true); + expect(seen[0][0]).toBe("+1.12"); + expect(hasChannel("1.12", throwRun)).toBe(false); + }); + + it("resolveChannelJulia joins Sys.BINDIR + returns null when the file is absent", () => { + // BINDIR that doesn't exist -> null (file check fails) + expect(resolveChannelJulia("1.12", () => "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/nonexistent/bin")).toBeNull(); + expect(resolveChannelJulia("1.12", throwRun)).toBeNull(); + }); + + it("resolveChannelJulia returns the path when the binary exists", () => { + const d = tmp(); + const bin = join(d, "bin"); + mkdirSync(bin, { recursive: true }); + writeFileSync(join(bin, process.platform === "win32" ? "julia.exe" : "julia"), ""); + expect(resolveChannelJulia("1.12", () => bin)).toBe(join(bin, process.platform === "win32" ? "julia.exe" : "julia")); + rmSync(d, { recursive: true, force: true }); + }); +}); + +describe("projectInstantiated", () => { + it("is true iff the project's Manifest exists", () => { + const d = tmp(); + expect(projectInstantiated(d)).toBe(false); + writeFileSync(join(d, "Manifest.toml"), ""); + expect(projectInstantiated(d)).toBe(true); + rmSync(d, { recursive: true, force: true }); + }); +}); + +describe("buildSetupSteps", () => { + const base = { + minor: "1.12", + project: "/home/u/.amico/julia", + projectSrc: "/ext/julia/Project.toml", + manifestSrc: "/ext/julia/Manifest.toml", + }; + + it("emits install + add + instantiate on a bare machine", () => { + const steps = buildSetupSteps({ ...base, juliaupPresent: false, channelPresent: false }); + expect(steps.map((s) => s.label)).toEqual(["Install juliaup", "Add Julia 1.12", "Instantiate Piccolo project"]); + expect(steps[0].command).toBe(juliaupInstallCommand()); + expect(steps[1].command).toBe("juliaup add 1.12"); + }); + + it("skips install when juliaup is present, skips add when the channel exists", () => { + expect(buildSetupSteps({ ...base, juliaupPresent: true, channelPresent: false }).map((s) => s.label)).toEqual([ + "Add Julia 1.12", + "Instantiate Piccolo project", + ]); + expect(buildSetupSteps({ ...base, juliaupPresent: true, channelPresent: true }).map((s) => s.label)).toEqual([ + "Instantiate Piccolo project", + ]); + }); + + it("instantiate runs through the channel + quotes paths", () => { + const steps = buildSetupSteps({ ...base, juliaupPresent: true, channelPresent: true }); + expect(steps[0].command).toContain('julia +1.12 --project="/home/u/.amico/julia"'); + expect(steps[0].command).toContain("Pkg.instantiate()"); + expect(steps[0].command).toContain('cp "/ext/julia/Manifest.toml" "/home/u/.amico/julia/Manifest.toml"'); + }); +}); From 90bf26d4db06189c6b4a42464efa29fc8f63297a Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 16 Jul 2026 01:10:08 -0400 Subject: [PATCH 2/2] feat(julia): first-run setup flow + install.sh auto-install (#8, layers 2-3) extension.ts: runJuliaSetup + amicode.setupJulia command + first-run gate (shouldOfferJuliaSetup), mirroring the vault-setup flow. Runs the juliaup install -> juliaup add -> instantiate steps in a visible terminal (the consent surface). install.sh: offer juliaup install when Julia is absent, add the pinned-minor channel, and route its own calls through 'julia +' (no global-default clobber). --- packages/extension/package.json | 4 ++ packages/extension/scripts/install.sh | 50 ++++++++++++++---- packages/extension/src/extension.ts | 76 +++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/packages/extension/package.json b/packages/extension/package.json index e7a6f9c8f..4ae72aaa2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -96,6 +96,10 @@ "command": "amicode.setupVault", "title": "Amicode: Set up a personal vault" }, + { + "command": "amicode.setupJulia", + "title": "Amicode: Set up Julia (juliaup)" + }, { "command": "amicode.openInspector", "title": "Amicode: Open Run Inspector" diff --git a/packages/extension/scripts/install.sh b/packages/extension/scripts/install.sh index 28d544e20..f55756146 100755 --- a/packages/extension/scripts/install.sh +++ b/packages/extension/scripts/install.sh @@ -10,19 +10,47 @@ LAB_TOML="$HOME/.amico/lab.toml" say() { printf '\033[1;35m[amicode]\033[0m %s\n' "$*"; } die() { printf '\033[1;31m[amicode] %s\033[0m\n' "$*" >&2; exit 1; } -# 1. Julia present? -command -v julia >/dev/null 2>&1 || die "Julia not found. Install: curl -fsSL https://install.julialang.org | sh (then re-run)" -say "julia: $(julia --version)" +# 1. Julia present? amicode manages the toolchain via juliaup. $JULIA is the +# command used below; it becomes "julia +" when juliaup-managed, so we +# pin the Manifest minor WITHOUT clobbering the user's global default. +JULIA="julia" +pinned_ver="$(sed -nE 's/^julia_version = "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$EXT_ROOT/julia/Manifest.toml" | head -1)" +pinned_minor="${pinned_ver%.*}" + +install_juliaup() { + say "installing juliaup (Julia version manager)..." + curl -fsSL https://install.julialang.org | sh -s -- --yes || die "juliaup install failed" + export PATH="$HOME/.juliaup/bin:$PATH" # available in THIS shell (juliaup edits the profile for future ones) +} -# 1b. Julia version vs the pinned Manifest. Manifest.toml is minor-format-specific -# (pins `julia_version`); instantiating it on a different MINOR drifts silently or -# fails confusingly, undercutting the deterministic-no-resolver-drift guarantee. +if ! command -v julia >/dev/null 2>&1 && ! command -v juliaup >/dev/null 2>&1; then + printf '\033[1;35m[amicode]\033[0m Julia not found. Install it now via juliaup? [Y/n] ' + read -r ans + case "${ans:-Y}" in [Nn]*) die "Julia is required. Install: curl -fsSL https://install.julialang.org | sh (then re-run)";; esac + install_juliaup +fi + +# Pin the Manifest minor via juliaup when available (add the channel if missing), +# and route this script's Julia calls through it via `julia +`. +if command -v juliaup >/dev/null 2>&1 && [ -n "$pinned_minor" ]; then + if ! julia "+${pinned_minor}" --startup-file=no --version >/dev/null 2>&1; then + say "adding Julia ${pinned_minor} via juliaup..." + juliaup add "${pinned_minor}" || die "juliaup add ${pinned_minor} failed" + fi + JULIA="julia +${pinned_minor}" +fi + +command -v julia >/dev/null 2>&1 || die "Julia still not found after setup — open a new shell (so the juliaup PATH edit takes) and re-run." +say "julia: $($JULIA --version)" + +# 1b. Version vs the pinned Manifest. Manifest.toml is minor-format-specific; +# instantiating on a different MINOR drifts silently. When juliaup-pinned above +# this always matches; the check still guards a manual (non-juliaup) julia. # Minor mismatch → fatal; patch mismatch → warn (a patch-level re-resolve is fine). -pinned_ver="$(sed -nE 's/^julia_version = "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' "$EXT_ROOT/julia/Manifest.toml" | head -1)" -running_ver="$(julia --version | sed -nE 's/.*[^0-9]([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')" +running_ver="$($JULIA --version | sed -nE 's/.*[^0-9]([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')" if [ -n "$pinned_ver" ] && [ -n "$running_ver" ]; then - if [ "${running_ver%.*}" != "${pinned_ver%.*}" ]; then - die "Julia minor mismatch: running $running_ver, Manifest pins $pinned_ver. Match the minor and re-run, e.g.: juliaup add ${pinned_ver%.*} && juliaup default ${pinned_ver%.*}" + if [ "${running_ver%.*}" != "${pinned_minor}" ]; then + die "Julia minor mismatch: running $running_ver, Manifest pins $pinned_ver. Install juliaup and re-run, or: juliaup add ${pinned_minor} && juliaup default ${pinned_minor}" elif [ "$running_ver" != "$pinned_ver" ]; then say "note: running Julia $running_ver differs from the Manifest's pinned patch $pinned_ver (minor matches; proceeding)." fi @@ -33,7 +61,7 @@ mkdir -p "$JULIA_PROJECT" cp "$EXT_ROOT/julia/Project.toml" "$JULIA_PROJECT/Project.toml" cp "$EXT_ROOT/julia/Manifest.toml" "$JULIA_PROJECT/Manifest.toml" say "instantiating Piccolo project at $JULIA_PROJECT (first run precompiles - be patient)..." -julia --project="$JULIA_PROJECT" -e 'using Pkg; Pkg.instantiate()' || die "Pkg.instantiate failed (see Julia error above)" +$JULIA --project="$JULIA_PROJECT" -e 'using Pkg; Pkg.instantiate()' || die "Pkg.instantiate failed (see Julia error above)" # 3. Install the VSIX if command -v code >/dev/null 2>&1; then diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 58473927d..45f34d98b 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -25,6 +25,14 @@ import { writeStopFile, savePulseTo, catalogPulsesDir, stopPlan, forceStop, runL import { watchSolverMode, applyEntitlementForMode, readSolverModeState } from "./solver_mode"; import { amicodeOpsDir } from "./substrate/vault_store"; import { createLocalPersonalVault, sanitizeVaultName, suggestVaultName, shouldOfferVaultSetup } from "./substrate/vault_setup"; +import { + pinnedJuliaMinor, + hasJuliaup, + hasChannel, + projectInstantiated, + shouldOfferJuliaSetup, + buildSetupSteps, +} from "./substrate/julia_setup"; import { resolveMountStack, personalMount, defaultVaultsRoot } from "./substrate/mount_store"; import { initDistillerTransport, triggerRunDistill, triggerSweep, type DistillerSetup } from "./substrate/distiller"; import * as os from "node:os"; @@ -618,6 +626,74 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { void runVaultSetup(false); } + // Julia setup (#8): amicode manages the Julia toolchain via juliaup — install + // juliaup if absent, add the channel pinned to the Manifest's MINOR, and + // instantiate the Piccolo project. Runs in a visible terminal (the consent + // surface for the network installer). We pin the MINOR channel (latest patch, + // e.g. 1.12.6 vs the Manifest's 1.12.3) — a patch drift install.sh already + // treats as fine. On a fresh machine juliaup makes this the default, so bare + // `julia` resolves to it; routing the runtime explicitly through the channel + // for pre-existing-juliaup setups is a follow-up (--julia / {{JULIA_BINARY}}). + const runJuliaSetup = async (fromCommand: boolean): Promise => { + const manifestSrc = path.resolve(ctx.extensionPath, "julia", "Manifest.toml"); + const projectSrc = path.resolve(ctx.extensionPath, "julia", "Project.toml"); + const minor = pinnedJuliaMinor(manifestSrc); + if (!minor) { + if (fromCommand) + void vscode.window.showErrorMessage("Amicode: could not read the pinned Julia version (julia/Manifest.toml)."); + return; + } + const project = resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")); + const juliaupPresent = hasJuliaup(); + const channelPresent = juliaupPresent && hasChannel(minor); + const ready = juliaupPresent && channelPresent && projectInstantiated(project); + if (ready) { + if (fromCommand) void vscode.window.showInformationMessage(`Amicode: Julia ${minor} is already set up.`); + return; + } + if (!fromCommand && ctx.globalState.get("amicode.juliaSetup.dismissed") === true) return; + if (!fromCommand) { + const choice = await vscode.window.showInformationMessage( + `Amicode uses Julia ${minor} (via juliaup) to run solves. Set it up now? The first run installs and precompiles the Piccolo project (a few minutes).`, + "Set up Julia", + "Not now", + "Don't ask again", + ); + if (choice === "Don't ask again") { + await ctx.globalState.update("amicode.juliaSetup.dismissed", true); + return; + } + if (choice !== "Set up Julia") return; + } + const steps = buildSetupSteps({ minor, juliaupPresent, channelPresent, project, projectSrc, manifestSrc }); + // Run in a visible terminal: the user watches the network installer + the + // precompile, and cancels by closing it. We deliberately DON'T pipe to a + // hidden task — transparency is the consent. + const term = vscode.window.createTerminal({ name: "Amicode: Julia setup" }); + term.show(); + term.sendText(steps.map((s) => `echo '[amicode] ${s.label}...' && ${s.command}`).join(" && \\\n")); + opencodeChannel.appendLine(`[julia] setup started (channel ${minor}, ${steps.length} step(s)) — see the terminal`); + void vscode.window.showInformationMessage( + `Amicode: setting up Julia ${minor} in the terminal. When it finishes, run "Amicode: Healthcheck" (or reload the window).`, + ); + }; + ctx.subscriptions.push(vscode.commands.registerCommand("amicode.setupJulia", () => void runJuliaSetup(true))); + // Auto-offer on first run when the toolchain isn't ready (juliaup/channel/ + // project missing) and the user hasn't dismissed. Command bypasses the gate. + if ( + shouldOfferJuliaSetup({ + juliaupPresent: hasJuliaup(), + channelPresent: (() => { + const m = pinnedJuliaMinor(path.resolve(ctx.extensionPath, "julia", "Manifest.toml")); + return m ? hasChannel(m) : false; + })(), + projectInstantiated: projectInstantiated(resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", ""))), + dismissed: ctx.globalState.get("amicode.juliaSetup.dismissed") === true, + }) + ) { + void runJuliaSetup(false); + } + // 5. Commands ctx.subscriptions.push( vscode.commands.registerCommand("amicode.openChat", async () => {