diff --git a/docker/fetch-al-extension.sh b/docker/fetch-al-extension.sh index c6f4ec4..e3ec682 100644 --- a/docker/fetch-al-extension.sh +++ b/docker/fetch-al-extension.sh @@ -15,6 +15,18 @@ EXTENSION="al" mkdir -p "${EXTENSION_DIR}" +# Validate that bin/linux/alc is the REAL native compiler (a self-contained ELF), +# not a corrupted stub. A stale/garbage alc on the shared state volume (e.g. a +# 252-byte self-exec shell wrapper) otherwise survives the version-marker cache +# skip and makes every compile hang in an infinite exec loop. ELF magic = 7f454c46. +is_real_alc() { + local f="$1" + [ -f "${f}" ] || return 1 + local magic + magic=$(head -c 4 "${f}" 2>/dev/null | od -An -tx1 2>/dev/null | tr -d ' \n') + [ "${magic}" = "7f454c46" ] +} + # --- Query marketplace for latest version --- echo "Checking AL extension version..." API_URL="https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" @@ -51,13 +63,19 @@ fi echo "Latest AL extension version: ${LATEST_VERSION}" # --- Check if already cached --- +# Re-extract when the version differs OR when the cached alc is corrupt — a +# matching version marker is NOT sufficient if the binary itself is garbage. if [ -f "${VERSION_FILE}" ]; then CACHED_VERSION=$(cat "${VERSION_FILE}") if [ "${CACHED_VERSION}" = "${LATEST_VERSION}" ]; then - echo "AL extension ${LATEST_VERSION} already cached" - exit 0 + if is_real_alc "${EXTENSION_DIR}/bin/linux/alc"; then + echo "AL extension ${LATEST_VERSION} already cached" + exit 0 + fi + echo "WARNING: cached AL extension ${LATEST_VERSION} has a corrupt alc (not an ELF binary) — re-extracting" + else + echo "Upgrading AL extension from ${CACHED_VERSION} to ${LATEST_VERSION}" fi - echo "Upgrading AL extension from ${CACHED_VERSION} to ${LATEST_VERSION}" fi # --- Download VSIX --- @@ -93,6 +111,17 @@ if [ ! -f "${AL_BINARY}" ]; then fi chmod +x "${AL_BINARY}" -# --- Write version marker --- -echo "${LATEST_VERSION}" > "${VERSION_FILE}" -echo "AL extension v${LATEST_VERSION} installed to ${EXTENSION_DIR}" +# --- Verify + make the alc compiler executable (the VSIX ships it -x'd) --- +# Without this the CLI fails with EACCES posix_spawn; with a corrupt alc it would +# otherwise hang. Validate it is a real ELF and bail loud if the VSIX changed shape. +ALC_LINUX="${EXTENSION_DIR}/bin/linux/alc" +if is_real_alc "${ALC_LINUX}"; then + chmod +x "${ALC_LINUX}" + # --- Write version marker --- + echo "${LATEST_VERSION}" > "${VERSION_FILE}" + echo "AL extension v${LATEST_VERSION} installed to ${EXTENSION_DIR}" +else + echo "WARNING: extracted alc is not a valid ELF binary at ${ALC_LINUX} — refusing to cache this version" + rm -f "${ALC_LINUX}" "${VERSION_FILE}" + exit 1 +fi diff --git a/docs/superpowers/plans/2026-06-22-al-toolchain-nuget-redesign.md b/docs/superpowers/plans/2026-06-22-al-toolchain-nuget-redesign.md new file mode 100644 index 0000000..de15215 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-al-toolchain-nuget-redesign.md @@ -0,0 +1,308 @@ +# AL Toolchain NuGet Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Make the Continia CLI the single owner of the AL compiler — fetch the self-contained `Microsoft.Dynamics.BusinessCentral.Development.Tools.Linux` nupkg from nuget.org (default: newest prerelease), cache + validate it, and invoke its `alc`. Drop the VSIX-alc scrape and the `al`→`alc` shim from DevOpsWorker. + +**Architecture:** CLI gains an AL-tool manager (resolve version → download nupkg → unzip to a version-keyed cache → validate → expose alc path + analyzer dir). `resolveToolchain` gets a `nuget` source (CONTINIA_ALC_PATH still wins). DevOpsWorker stops fetching alc and removes the shim; keeps the VSIX only for the LSP host. + +**Tech Stack:** TypeScript on Bun, `bun:test`. Spike-proven: `.Tools.Linux` alc is a self-contained ELF (no .NET runtime), compiles headless in ~1 s, analyzers bundled in `lib/net10.0/`. + +**Design:** `docs/superpowers/specs/2026-06-22-al-toolchain-nuget-redesign-design.md` + +**Repos:** `U:\Git\CLI` (most tasks) + `DevOpsWorker` (container). + +## Verified facts (use these literally) +- Versions index: `GET https://api.nuget.org/v3-flatcontainer/microsoft.dynamics.businesscentral.development.tools.linux/index.json` → `{"versions":[...]}`. Prerelease versions carry a `-beta` suffix. Stable tops at `17.0.34.45391`; 18.x are all `-beta`. +- Download: `GET https://api.nuget.org/v3-flatcontainer///..nupkg` (id + ver lowercase). It is a zip; 61 MB. +- Inside: `lib/net10.0/alc` (78 KB ELF, self-contained), analyzers `lib/net10.0/Microsoft.Dynamics.Nav.{CodeCop,AppSourceCop,UICop}.dll`. +- Existing CLI guard already merged path: `validateAlcBinary(path)` in `src/core/compiler.ts` returns null if ELF/PE, else an error string. +- `resolveAnalyzers(options)` (`src/core/analyzer-resolver.ts`) looks for DLLs in `/bin/Analyzers/` — the nuget layout has them flat in `lib/net10.0/`, so the manager must expose that dir and the resolver must accept it. + +--- + +## File Structure +- `U:\Git\CLI/src/core/al-nuget.ts` — **new**: version resolution + nupkg download/unzip/cache/validate. Pure-ish, HTTP + fs injected for tests. +- `U:\Git\CLI/src/core/al-toolchain.ts` — add a `nuget` source to `resolveToolchain`. +- `U:\Git\CLI/src/core/analyzer-resolver.ts` — also accept analyzers directly under `alExtPath` (nuget flat layout). +- `U:\Git\CLI/src/cli/commands/compile.ts` + `deploy.ts` — add `--alc-version` / `--stable` flags, thread to toolchain. +- `U:\Git\CLI/tests/core/al-nuget.test.ts` — **new**. +- `DevOpsWorker/docker/entrypoint.sh` — remove the `al` shim; export `CONTINIA_ALC_CACHE` → `/state/tools/alc`. +- `DevOpsWorker/docker/fetch-al-extension.sh` — trim to LSP-host only (stop owning alc). + +--- + +## Task 1: Version resolution (`al-nuget.ts`) + +**Files:** Create `U:\Git\CLI/src/core/al-nuget.ts`; Test `U:\Git\CLI/tests/core/al-nuget.test.ts` + +- [ ] **Step 1: Failing test** — create `tests/core/al-nuget.test.ts`: + +```ts +import { describe, test, expect } from "bun:test"; +import { pickAlcVersion } from "../../src/core/al-nuget"; + +const VERSIONS = [ + "16.0.28.13140", "17.0.34.45391", + "18.0.36.64936-beta", "18.0.37.7221-beta", "18.0.37.11445-beta", +]; + +describe("pickAlcVersion", () => { + test("default = newest including prerelease", () => { + expect(pickAlcVersion(VERSIONS, {})).toBe("18.0.37.11445-beta"); + }); + test("--stable = newest without -beta", () => { + expect(pickAlcVersion(VERSIONS, { stable: true })).toBe("17.0.34.45391"); + }); + test("explicit pin wins and must exist", () => { + expect(pickAlcVersion(VERSIONS, { version: "18.0.36.64936-beta" })).toBe("18.0.36.64936-beta"); + }); + test("explicit pin not found throws", () => { + expect(() => pickAlcVersion(VERSIONS, { version: "99.0.0.0" })).toThrow(/not found/); + }); +}); +``` + +- [ ] **Step 2: Run — fails** (`bun test tests/core/al-nuget.test.ts`): module missing. + +- [ ] **Step 3: Implement `pickAlcVersion`** in `src/core/al-nuget.ts`: + +```ts +export interface AlcVersionOpts { + /** Exact version pin (wins over all). */ + version?: string; + /** Opt out of prerelease — newest stable only. */ + stable?: boolean; +} + +const isPrerelease = (v: string) => v.includes("-"); + +/** 4-part dotted compare; treats a prerelease (`-beta`) as lower than its stable. */ +function compareVersions(a: string, b: string): number { + const [ca, pa = ""] = a.split("-"); + const [cb, pb = ""] = b.split("-"); + const na = ca.split(".").map(Number); + const nb = cb.split(".").map(Number); + for (let i = 0; i < 4; i++) { + const d = (na[i] ?? 0) - (nb[i] ?? 0); + if (d !== 0) return d; + } + if (pa === pb) return 0; + if (pa === "") return 1; // stable > its own prerelease + if (pb === "") return -1; + return pa < pb ? -1 : 1; +} + +/** Choose the alc version from a nuget flat-container version list. */ +export function pickAlcVersion(versions: string[], opts: AlcVersionOpts): string { + if (opts.version) { + if (!versions.includes(opts.version)) { + throw new Error(`AL compiler version "${opts.version}" not found on the feed. Available newest: ${[...versions].sort(compareVersions).slice(-3).reverse().join(", ")}`); + } + return opts.version; + } + const pool = opts.stable ? versions.filter((v) => !isPrerelease(v)) : versions; + if (pool.length === 0) throw new Error("No AL compiler versions available on the feed"); + return [...pool].sort(compareVersions).at(-1)!; +} +``` + +- [ ] **Step 4: Run — passes** (`bun test tests/core/al-nuget.test.ts`). + +- [ ] **Step 5: Commit** + +```bash +git add src/core/al-nuget.ts tests/core/al-nuget.test.ts +git commit -m "feat(al-nuget): alc version resolution (default prerelease, --stable, pin)" +``` + +--- + +## Task 2: Download + unzip + cache + validate (`al-nuget.ts`) + +**Files:** Modify `src/core/al-nuget.ts`; Test `tests/core/al-nuget.test.ts` + +Inject the network + unzip so the test is hermetic (no real 61 MB download). + +- [ ] **Step 1: Failing test** — append: + +```ts +import { ensureAlc } from "../../src/core/al-nuget"; +import { mkdtempSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; + +describe("ensureAlc", () => { + test("downloads + extracts once, then reuses the cached valid alc", async () => { + const cacheRoot = mkdtempSync(path.join(tmpdir(), "alc-cache-")); + let downloads = 0; + const deps = { + listVersions: async () => ["18.0.37.11445-beta"], + download: async (_url: string) => { downloads++; return Buffer.from("ZIPBYTES"); }, + // fake unzip: write a real ELF alc + an analyzer dll into /lib/net10.0 + unzip: async (_buf: Buffer, dest: string) => { + const d = path.join(dest, "lib", "net10.0"); + mkdirSync(d, { recursive: true }); + writeFileSync(path.join(d, "alc"), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1, 0])); + writeFileSync(path.join(d, "Microsoft.Dynamics.Nav.CodeCop.dll"), "x"); + }, + }; + const a = await ensureAlc({ cacheRoot }, {}, deps); + expect(existsSync(a.alcPath)).toBe(true); + expect(a.analyzerDir.endsWith(path.join("lib", "net10.0"))).toBe(true); + const b = await ensureAlc({ cacheRoot }, {}, deps); // second call: cached + expect(b.alcPath).toBe(a.alcPath); + expect(downloads).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run — fails** (`ensureAlc` missing). + +- [ ] **Step 3: Implement** `ensureAlc` + the default deps in `src/core/al-nuget.ts`: + +```ts +import { existsSync, mkdirSync, chmodSync, rmSync } from "fs"; +import path from "path"; +import { validateAlcBinary } from "./compiler"; + +const PKG = "microsoft.dynamics.businesscentral.development.tools.linux"; +const FEED = "https://api.nuget.org/v3-flatcontainer"; + +export interface EnsureAlcConfig { cacheRoot: string; } +export interface ResolvedAlc { version: string; alcPath: string; analyzerDir: string; } + +export interface AlNugetDeps { + listVersions: () => Promise; + download: (url: string) => Promise; + unzip: (buf: Buffer, destDir: string) => Promise; +} + +/** Default deps: real nuget.org + a zip extractor. */ +export function defaultDeps(): AlNugetDeps { + return { + listVersions: async () => { + const r = await fetch(`${FEED}/${PKG}/index.json`); + if (!r.ok) throw new Error(`nuget index ${r.status} for ${PKG}`); + return ((await r.json()) as { versions: string[] }).versions ?? []; + }, + download: async (url) => { + const r = await fetch(url); + if (!r.ok) throw new Error(`nuget download ${r.status}: ${url}`); + return Buffer.from(await r.arrayBuffer()); + }, + unzip: async (buf, destDir) => { await extractZip(buf, destDir); }, // see Task 2a + }; +} + +/** Resolve + ensure the alc for the requested version is present in the cache. */ +export async function ensureAlc( + cfg: EnsureAlcConfig, + opts: AlcVersionOpts, + deps: AlNugetDeps = defaultDeps(), +): Promise { + const version = pickAlcVersion(await deps.listVersions(), opts); + const verDir = path.join(cfg.cacheRoot, version); + const alcPath = path.join(verDir, "lib", "net10.0", "alc"); + const analyzerDir = path.dirname(alcPath); + + if (existsSync(alcPath) && validateAlcBinary(alcPath) === null) { + return { version, alcPath, analyzerDir }; + } + + // (Re)install: download nupkg, unzip into verDir, chmod, validate. + rmSync(verDir, { recursive: true, force: true }); + mkdirSync(verDir, { recursive: true }); + const url = `${FEED}/${PKG}/${version}/${PKG}.${version}.nupkg`; + const buf = await deps.download(url); + await deps.unzip(buf, verDir); + if (!existsSync(alcPath)) throw new Error(`alc not found at ${alcPath} after extracting ${PKG} ${version}`); + try { chmodSync(alcPath, 0o755); } catch { /* windows */ } + const err = validateAlcBinary(alcPath); + if (err) throw new Error(`Extracted alc is invalid: ${err}`); + return { version, alcPath, analyzerDir }; +} +``` + +- [ ] **Step 4: Run — passes**. + +- [ ] **Step 5: Commit** (`feat(al-nuget): download/unzip/cache/validate alc nupkg`). + +--- + +## Task 2a: Zip extraction helper (`extractZip`) + +**Decision:** the nupkg is a standard zip. Node/Bun has no built-in unzip. **Verify first** whether the CLI already depends on a zip lib (it extracts VSIX elsewhere?): `grep -rnE "adm-zip|yauzl|unzipper|JSZip|fflate|Bun.*unzip" package.json src`. + +- [ ] **Step 1:** If a zip lib is already a dep, use it. Else add `fflate` (pure JS, no native) — `bun add fflate` — and implement: + +```ts +import { unzipSync } from "fflate"; +import { writeFileSync, mkdirSync } from "fs"; +import path from "path"; + +export async function extractZip(buf: Buffer, destDir: string): Promise { + const files = unzipSync(new Uint8Array(buf)); + for (const [name, data] of Object.entries(files)) { + if (name.endsWith("/")) continue; + const out = path.join(destDir, name); + mkdirSync(path.dirname(out), { recursive: true }); + writeFileSync(out, data); + } +} +``` + +- [ ] **Step 2:** Add a test extracting a tiny in-memory zip (build with `fflate.zipSync`) and assert a file lands on disk. +- [ ] **Step 3:** Run + commit (`feat(al-nuget): zip extraction via fflate`). + +(If `fflate` is undesirable, the fallback is shelling to `unzip` — but that breaks Windows local use; prefer the JS lib.) + +--- + +## Task 3: Wire `nuget` source into `resolveToolchain` + analyzers + +**Files:** `src/core/al-toolchain.ts`, `src/core/analyzer-resolver.ts` + +- [ ] **Step 1:** In `resolveToolchain`, after the `CONTINIA_ALC_PATH` check (which still wins), add a `nuget` branch that, when a managed alc exists, returns `{ alcPath, argv0Args: [], source: "nuget", alExtPath: }`. (The async ensure happens in the command before calling `compile`; `resolveToolchain` itself stays sync — pass the resolved alc path via `CONTINIA_ALC_PATH` or a new optional arg. **Choose:** the command calls `ensureAlc`, sets `process.env.CONTINIA_ALC_PATH = alcPath` and an analyzer-dir hint, so the existing sync `resolveToolchain` picks it up via the env branch — minimal change.) Add `"nuget"` to the `source` union type if a distinct source is used. + +- [ ] **Step 2:** `analyzer-resolver.ts`: when `/bin/Analyzers/` does not exist, fall back to `/` (the flat nuget layout). Add a unit test with a temp dir holding `Microsoft.Dynamics.Nav.CodeCop.dll` directly under `alExtPath` and assert it resolves. + +- [ ] **Step 3:** Run analyzer + toolchain tests; commit (`feat(al): use nuget alc + flat analyzer layout`). + +--- + +## Task 4: Command flags + +**Files:** `src/cli/commands/compile.ts`, `deploy.ts` + +- [ ] **Step 1:** Add `.option("--alc-version ", "Pin the AL compiler version")` and `.option("--stable", "Use the newest stable alc (default: newest prerelease)")` to both commands. +- [ ] **Step 2:** Before `compile(...)`/the deploy loop, resolve the cache root (`process.env.CONTINIA_ALC_CACHE ?? path.join(os.homedir(), ".continia", "alc")`), call `ensureAlc({cacheRoot}, {version: opts.alcVersion, stable: opts.stable})`, set `process.env.CONTINIA_ALC_PATH = alcPath` (so `compile()` uses it) and pass the analyzer dir. +- [ ] **Step 3:** Smoke (manual/CI): `continia compile Cloud --json` in the WI-79397 workspace → completes with real compiler output, no hang; `--stable` selects 17.x, default selects 18.x-beta. +- [ ] **Step 4:** Commit (`feat(cli): --alc-version/--stable flags; auto-provision alc`). + +--- + +## Task 5: DevOpsWorker container + +**Files:** `DevOpsWorker/docker/entrypoint.sh`, `docker/fetch-al-extension.sh` + +- [ ] **Step 1:** `entrypoint.sh` — remove the `al`→`alc` shim block; `export CONTINIA_ALC_CACHE="${AL_TOOLS_DIR}/alc"` (persists on the state volume). Do NOT export the alc onto PATH anymore. +- [ ] **Step 2:** `fetch-al-extension.sh` — keep ONLY the LSP host extraction (`EditorServices.Host`); stop being the alc source (the `is_real_alc` self-heal guard can stay as defense for the LSP-era cache, or be removed with the alc role). The CLI now owns alc. +- [ ] **Step 3:** Rebuild prod image (`pwsh private/deploy/docker-build.ps1`); run the WI-79397 Cloud compile end-to-end inside a fresh container (empty alc cache) → CLI auto-downloads `.Tools.Linux` (default prerelease), compiles **sub-30 s**, analyzers load. +- [ ] **Step 4:** Commit (`feat(docker): CLI owns alc; drop VSIX-alc + al shim`). + +--- + +## Final Verification +- [ ] `bun test` green in `U:\Git\CLI`. +- [ ] Fresh container, empty caches: `continia compile` on WI-79397 Cloud → sub-30 s, analyzers loaded, no hang. +- [ ] `--stable` vs default select 17.x vs 18.x-beta respectively. +- [ ] A deliberately-corrupt `CONTINIA_ALC_PATH` fails loud (validateAlcBinary), does not hang. + +## Spec Coverage +- CLI owns alc via NuGet `.Tools.Linux` (self-contained, no .NET runtime) → T1–T4. +- Default prerelease + `--stable`/`--alc-version` → T1, T4. +- Validate-before-use → reuses shipped `validateAlcBinary` (T2 ensure + T4). +- Analyzers from flat `lib/net10.0/` → T3. +- DevOpsWorker drops VSIX-alc + shim, keeps VSIX for LSP → T5. +- Open: BC-major clamp (optional refinement, deferred); LSP-from-nuget (out of scope). diff --git a/docs/superpowers/specs/2026-06-22-al-toolchain-nuget-redesign-design.md b/docs/superpowers/specs/2026-06-22-al-toolchain-nuget-redesign-design.md new file mode 100644 index 0000000..13a3670 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-al-toolchain-nuget-redesign-design.md @@ -0,0 +1,135 @@ +# AL Toolchain Redesign — Continia CLI owns alc via NuGet/.NET + +**Date:** 2026-06-22 +**Status:** Design (spike-proven), pending implementation plan +**Repos:** `U:\Git\CLI` (Continia CLI) + `DevOpsWorker` (container/entrypoint) + +## Problem + +The AL compiler (`alc`) is currently obtained and managed by **DevOpsWorker**, not +the Continia CLI, and discovered by the CLI through a fragile 3-tier guess. This +split ownership produced a production failure: a corrupted `alc` (a 252-byte +self-executing shell wrapper) sat cached on the shared `do-pipeline-state` volume, +survived the version-marker cache skip, and made **every compile hang in an +infinite exec loop** — costing one work item ~2.6 h / $16 / 200 turns before the +coder gave up. The real `alc` compiles the same project in **~1 second**. + +### Current mechanism (the smell) +- **DevOpsWorker** `docker/fetch-al-extension.sh` scrapes the **VS Code + Marketplace** for the *latest* `ms-dynamics-smb.al` VSIX, extracts `bin/` to + `/state/tools/al-extension/`, caches by a `.version` marker, and the entrypoint + hand-writes an `al`→`alc` shim onto `$PATH`. +- **Continia CLI** `src/core/al-toolchain.ts:resolveToolchain` checks + `CONTINIA_ALC_PATH` (unset) → `~/.vscode/extensions/ms-dynamics-smb.al-*` + (absent in the container) → falls back to invoking bare `al` (the shim). It + ignores `AL_EXTENSION_PATH` (the var the container actually exports) and never + validates that the resolved `alc` is a real binary. +- Nobody owns alc end-to-end; a garbage file silently becomes a 2.6 h hang. + +(Stop-gaps already shipped on branches `fix/alc-cache-guard` + `fix/alc-binary-guard`: +fetch self-heals a corrupt cache + `chmod +x`; the CLI `validateAlcBinary` fails +loud on a non-binary alc. Those are guards, not the fix.) + +## Decision + +Make the **Continia CLI the single owner of the AL compiler**, sourced from the +**official Microsoft NuGet AL tools** (`Microsoft.Dynamics.BusinessCentral.Development.Tools`) +instead of scraping the VSIX. DevOpsWorker stops managing alc. + +### Spike evidence (in `devopsworker:latest`, against the real WI-79397 app) +- .NET 8 installs cleanly via `curl https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0` (no apt feed). +- `dotnet tool install --global Microsoft.Dynamics.BusinessCentral.Development.Tools` → `al` tool from **nuget.org, public, no auth**. Stable = `17.0.34`; `--prerelease` = `18.0.37.11445-beta`. +- `al compile /project:

/packagecachepath:

` is a thin alc wrapper (**same args** as alc), runs **headless on Linux in 0–1 s**, returns real `AL1022`/`AL1018` errors — **no hang**. +- **Analyzers bundled** (`Microsoft.Dynamics.Nav.CodeCop.dll`, `…AppSourceCop.dll`). +- nuget.org carries `16.x`/`17.x`/`18.x` + `18.x-beta` → version is pinnable; `--prerelease` is the preview channel. +- Platform-specific package `…Tools.Linux` is **self-contained** (RESOLVED): its + nupkg ships alc at `lib/net10.0/alc` (the same 78 KB ELF) bundling its own + runtime (`libcoreclr.so`, `libhostfxr.so`, 414 files). Verified: alc runs and + compiles the 552-file Cloud app **with no `dotnet` installed**. So **no .NET + runtime is needed in the image** — just download + unzip the nupkg. + - Caveat: `…Tools.Linux` 18.x is currently **`-beta` only** (stable tops at + 17.0.34). Because BC 28/29 needs 18.x, the CLI **defaults to the prerelease + channel** (see Version resolution); `--stable` opts out. + +Why this is the right architecture, not symptom-patching: it deletes the entire +fragile subsystem — marketplace scrape, `.version` cache, self-exec shim, missing +`chmod`, discovery guessing, "latest vs needed" version drift. Every defect we +hit becomes **structurally impossible**, and it's the supported MS distribution. + +## Design + +### 1. Continia CLI — own the toolchain lifecycle +A toolchain manager that does: **resolve version → ensure installed → discover → +validate → invoke**. + +- **Version resolution** (precedence): + 1. explicit `--alc-version ` flag / config (exact pin, wins over all); + 2. `--stable` / `--no-prerelease` opt-out → newest **stable** only; + 3. **DEFAULT: newest prerelease** (include `-beta`). Rationale: the current BC + 28/29 line ships alc as `18.x-beta` only (stable tops at 17.0.34), so + defaulting to stable would hand BC 29 projects a too-old compiler. Prerelease + is the working default; `--stable`/explicit pin are the escape hatches. + - Optional refinement: clamp the chosen version's major to the project's BC + platform (`app.json` `application`/`platform`) so "newest prerelease" can't + jump a major ahead of the target. +- **Ensure installed:** download the **self-contained `…Tools.Linux` nupkg** for + the resolved version from nuget.org (`v3-flatcontainer///..nupkg`, + public, no auth), unzip to a CLI-managed cache, `chmod +x lib/net10.0/alc`. No + `dotnet tool install`, **no .NET runtime** — the package bundles its own. + Idempotent; keyed by version so multiple BC versions coexist. (A `dotnet tool` + install is the cross-platform alternative but needs the runtime; the + self-contained platform nupkg avoids it on Linux.) +- **Discover:** deterministic path under the CLI cache — no `~/.vscode` / + `AL_EXTENSION_PATH` guessing. +- **Validate:** reuse `validateAlcBinary` (already added) — a non-executable / + stub fails loud, never hangs. +- **Invoke:** `al compile ` (the tool wraps alc and accepts the same + `/project:` args `buildCompileArgs` already emits) — or the bundled `alc` + directly. Analyzers resolve from the tool's own package (drop the + `alExtPath`-from-VSIX analyzer resolution). +- **Remove** the `altool-fallback` path and the assumption that an `al` shim + exists on `$PATH`. `resolveToolchain` collapses to "the CLI-managed alc". + +### 2. DevOpsWorker — stop managing alc +- Drop `fetch-al-extension.sh`'s alc responsibility and the entrypoint `al`→`alc` + shim. The container just calls `continia compile`. +- **No .NET runtime needed** — the `…Tools.Linux` nupkg is self-contained + (verified). The CLI does the download+unzip itself. +- **Keep** the VSIX fetch **only** for the AL **LSP** server + (`Microsoft.Dynamics.Nav.EditorServices.Host`), which the LSP plugin still + needs — unless that is also sourced from NuGet. Trim the fetch to just the LSP + host to shrink it. + +### 3. Backwards-compat / rollout +- The CLI change is additive: if a `CONTINIA_ALC_PATH` is set it still wins + (escape hatch + the validation guard protects it). +- Ship the CLI first (it can self-provision alc even on the current image once + .NET is present), then slim the container (remove shim + VSIX-alc). + +## Open Questions (resolve before/within implementation) +1. **`…Tools.Linux` self-containment** — RESOLVED: self-contained, **no .NET + runtime needed** (verified — alc compiled with no dotnet present). Remaining: + confirm the **analyzers** (CodeCop/AppSourceCop) ship inside `…Tools.Linux`'s + `lib/net10.0/` (very likely — 414 files — but verify), else source them + separately. +2. **BC-version → alc-version mapping** — confirm the policy (match major to the + app's `application` version vs. always-newest). The app targets BC 28/29; + nuget has matching 18.x. Tie into the existing 3-version-axes knowledge + ([[project_deps_wrong_major_28v29]]). +3. **LSP host source** — keep a trimmed VSIX pull for `EditorServices.Host`, or is + the language server also on NuGet? +4. **Feed pinning/caching** — pin to nuget.org; decide on an offline/restore cache + for reproducibility and to avoid per-container network installs. + +## Testing / verification +- CLI unit: `validateAlcBinary` (done); toolchain version-resolution + + preview-flag selection; discovery returns the CLI-managed path. +- Container integration: `continia compile` on the WI-79397 Cloud app (with + symbols via `deps download`) completes **sub-30 s** with analyzers loaded; a + poisoned/absent alc fails loud, not hangs. +- Regression: confirm a fresh container (empty caches) self-provisions alc and + compiles with no manual steps. + +## Related +- [[project_alc_toolchain_redesign]] (root cause + spike results) +- [[project_continia_cli_skill_sync]], [[project_env_publish_logo_backslash]]