diff --git a/.github/workflows/launcher-artefacts.yml b/.github/workflows/launcher-artefacts.yml index d59f513..2ccb982 100644 --- a/.github/workflows/launcher-artefacts.yml +++ b/.github/workflows/launcher-artefacts.yml @@ -122,6 +122,33 @@ jobs: fi exit 0 + - name: Install just (the provisioning engine's runner) + # tests/provisioning_fixtures.rs drives the real engine, which needs + # just >= 1.42 and fails loudly without it. A release tarball pinned by + # sha256 (casey/just SHA256SUMS) adds no action to pin. + shell: bash + env: + JUST_VERSION: "1.56.0" + JUST_SHA256: fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639 + run: | + set +e + . "$RUNNER_TEMP/annotate.sh" + tgz="$RUNNER_TEMP/just.tar.gz" + curl -fsSL -o "$tgz" \ + "https://github.com/casey/just/releases/download/${JUST_VERSION}/just-${JUST_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + > "$RUNNER_TEMP/just.log" 2>&1 \ + && echo "${JUST_SHA256} $tgz" | sha256sum -c - >> "$RUNNER_TEMP/just.log" 2>&1 \ + && mkdir -p "$RUNNER_TEMP/bin" \ + && tar -xzf "$tgz" -C "$RUNNER_TEMP/bin" just >> "$RUNNER_TEMP/just.log" 2>&1 + STATUS=$? + if [ "$STATUS" -ne 0 ]; then + annotate "installing just ${JUST_VERSION} failed" "$RUNNER_TEMP/just.log" 20 + exit "$STATUS" + fi + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/bin/just" --version + exit $? + - name: Build every target shell: bash run: | @@ -178,6 +205,13 @@ jobs: echo "::error::$FAILED test(s) failed" exit 1 fi + # rust-ci.yml skips `needs_just::` (its runner has no just); this job + # is the only place they run, so their absence here is a failure. + NEEDS_JUST=$(grep -cE '^test (.*::)?needs_just::.* \.\.\. ok$' "$LOG") + if [ "$NEEDS_JUST" -lt 16 ]; then + echo "::error title=needs_just tests::only $NEEDS_JUST of 16 needs_just:: tests passed here, and rust-ci skips them, so nothing else runs them" + exit 1 + fi if [ "$PASSED" -lt "$TEST_COUNT_FLOOR" ]; then echo "::error title=test-count floor::only $PASSED tests ran; the floor is $TEST_COUNT_FLOOR. A suite that collects nothing exits 0, so this gate is what makes green mean ran (#45 AC6)." exit 1 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index e8baf25..bf5f23f 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -19,7 +19,8 @@ # check → cargo check --locked --all-targets # cargo fmt --all -- --check # cargo clippy --locked --all-targets -- -D warnings -# test → cargo test --locked --all-targets +# test → cargo test --locked --all-targets, skipping `needs_just::` +# (run with just installed by launcher-artefacts.yml) # # The job names below are static and unmatrixed on purpose (#45 AC3): the # rendered check names ("rust-ci / Cargo test" and friends) have to be stable @@ -42,3 +43,7 @@ permissions: jobs: rust-ci: uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@81dbf2dd854b1444fd6236fa2352474383b2c2b9 + with: + # The provisioning engine tests (module `needs_just`) need `just`, which + # this runner lacks; launcher-artefacts.yml installs it and runs them. + test_args: "--all-targets -- --skip needs_just::" diff --git a/.gitignore b/.gitignore index b423cbb..c72956a 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,8 @@ deps/ .elixir_ls/ .cache/ build/ +# The provisioning canon vendors build/just/ engine files, and its fixtures +# mirror a repo root; both are source here, not build output. +!standards/provisioning/templates/build/ +!crates/launcher-common/tests/fixtures/provisioning/**/build/ dist/ diff --git a/.machine_readable/contractiles/Justfile b/.machine_readable/contractiles/Justfile index adfb82e..68f39c1 100644 --- a/.machine_readable/contractiles/Justfile +++ b/.machine_readable/contractiles/Justfile @@ -73,29 +73,47 @@ smoke-mint: @echo "Smoke test: mint produced /tmp/stapeln-launcher.sh" # Mint a launcher in-place. Pass the path to an .launcher.a2ml file. -# Example: just mint /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml +# Example: just mint ../aerie/aerie.launcher.a2ml mint config: cargo run --locked --release -p launch-scaffolder -- mint {{config}} -# Re-mint every scaffolder-managed launcher in the estate. Edit the list -# when adding/removing managed repos. Exceptions live in +# Re-mint every scaffolder-managed launcher found under ROOT (the directory +# holding your clones; nothing machine-specific is assumed). Each managed +# config must resolve to exactly one file: a missing or duplicated clone is +# an error naming the candidates, never a guess. Edit the list when +# adding/removing managed repos. Exceptions live in # docs/launcher-exceptions-2026-04-10.adoc. -mint-all: +mint-all root: #!/usr/bin/env bash set -euo pipefail BIN="./target/release/launch-scaffolder" + [ -d "{{root}}" ] || { echo "✗ {{root}} is not a directory" >&2; exit 2; } [ -x "$BIN" ] || cargo build --locked --release - for cfg in \ - /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/burble/burble.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/game-server-admin/game-server-admin.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/nqc.launcher.a2ml \ - /var/mnt/eclipse/repos/verification-ecosystem/panll/panll.launcher.a2ml \ - /var/mnt/eclipse/repos/project-wharf/project-wharf.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/stapeln/stapeln.launcher.a2ml ; do - "$BIN" mint "$cfg" + status=0 n=0 + for rel in \ + aerie/aerie.launcher.a2ml \ + burble/burble.launcher.a2ml \ + game-server-admin/game-server-admin.launcher.a2ml \ + nqc/nqc.launcher.a2ml \ + panll/panll.launcher.a2ml \ + project-wharf/project-wharf.launcher.a2ml \ + stapeln/stapeln.launcher.a2ml ; do + mapfile -d '' hits < <(find "{{root}}" -path '*/worktrees' -prune -o -path '*/archive' -prune \ + -o -path "*/$rel" -type f -print0) + if [ "${#hits[@]}" -ne 1 ]; then + echo "✗ $rel: ${#hits[@]} matches under {{root}} (need exactly one)" >&2 + for h in "${hits[@]}"; do echo " $h" >&2; done + status=1; continue + fi + if "$BIN" mint "${hits[0]}"; then + n=$((n + 1)) + else + echo "✗ $rel: mint failed" >&2 + status=1 + fi done - echo "✓ Estate re-mint complete (7 launchers)" + echo "Estate re-mint: $n of 7 launchers" + exit "$status" # Generate cargo docs doc: diff --git a/Justfile b/Justfile index adfb82e..68f39c1 100644 --- a/Justfile +++ b/Justfile @@ -73,29 +73,47 @@ smoke-mint: @echo "Smoke test: mint produced /tmp/stapeln-launcher.sh" # Mint a launcher in-place. Pass the path to an .launcher.a2ml file. -# Example: just mint /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml +# Example: just mint ../aerie/aerie.launcher.a2ml mint config: cargo run --locked --release -p launch-scaffolder -- mint {{config}} -# Re-mint every scaffolder-managed launcher in the estate. Edit the list -# when adding/removing managed repos. Exceptions live in +# Re-mint every scaffolder-managed launcher found under ROOT (the directory +# holding your clones; nothing machine-specific is assumed). Each managed +# config must resolve to exactly one file: a missing or duplicated clone is +# an error naming the candidates, never a guess. Edit the list when +# adding/removing managed repos. Exceptions live in # docs/launcher-exceptions-2026-04-10.adoc. -mint-all: +mint-all root: #!/usr/bin/env bash set -euo pipefail BIN="./target/release/launch-scaffolder" + [ -d "{{root}}" ] || { echo "✗ {{root}} is not a directory" >&2; exit 2; } [ -x "$BIN" ] || cargo build --locked --release - for cfg in \ - /var/mnt/eclipse/repos/aerie/aerie.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/burble/burble.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/game-server-admin/game-server-admin.launcher.a2ml \ - /var/mnt/eclipse/repos/developer-ecosystem/nextgen-databases/nqc/nqc.launcher.a2ml \ - /var/mnt/eclipse/repos/verification-ecosystem/panll/panll.launcher.a2ml \ - /var/mnt/eclipse/repos/project-wharf/project-wharf.launcher.a2ml \ - /var/mnt/eclipse/repos/fleet-ecosystem/stapeln/stapeln.launcher.a2ml ; do - "$BIN" mint "$cfg" + status=0 n=0 + for rel in \ + aerie/aerie.launcher.a2ml \ + burble/burble.launcher.a2ml \ + game-server-admin/game-server-admin.launcher.a2ml \ + nqc/nqc.launcher.a2ml \ + panll/panll.launcher.a2ml \ + project-wharf/project-wharf.launcher.a2ml \ + stapeln/stapeln.launcher.a2ml ; do + mapfile -d '' hits < <(find "{{root}}" -path '*/worktrees' -prune -o -path '*/archive' -prune \ + -o -path "*/$rel" -type f -print0) + if [ "${#hits[@]}" -ne 1 ]; then + echo "✗ $rel: ${#hits[@]} matches under {{root}} (need exactly one)" >&2 + for h in "${hits[@]}"; do echo " $h" >&2; done + status=1; continue + fi + if "$BIN" mint "${hits[0]}"; then + n=$((n + 1)) + else + echo "✗ $rel: mint failed" >&2 + status=1 + fi done - echo "✓ Estate re-mint complete (7 launchers)" + echo "Estate re-mint: $n of 7 launchers" + exit "$status" # Generate cargo docs doc: diff --git a/crates/launcher-common/src/lib.rs b/crates/launcher-common/src/lib.rs index 618f2dc..b6d5ca4 100644 --- a/crates/launcher-common/src/lib.rs +++ b/crates/launcher-common/src/lib.rs @@ -29,6 +29,7 @@ pub mod integration; pub mod integrity; pub mod metadata_block; pub mod platform; +pub mod provisioning; pub mod standard; pub mod template; diff --git a/crates/launcher-common/src/provisioning/canon.rs b/crates/launcher-common/src/provisioning/canon.rs new file mode 100644 index 0000000..85526ac --- /dev/null +++ b/crates/launcher-common/src/provisioning/canon.rs @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! The provisioning canon: `standards/3-practice/provisioning/templates`, +//! vendored at `standards/provisioning/` and baked into the binary. +//! +//! The baked table is the default. `--canon DIR` (or +//! `$LAUNCH_SCAFFOLDER_PROVISIONING_CANON`) selects a directory laid out like +//! `standards/provisioning/` instead — a `CANON` file and a `templates/` tree — +//! so an unreleased canon can be tried without rebuilding. +//! +//! Two tests keep the table honest: it must list exactly the files under +//! `templates/` (a file added to the vendor copy and not to the table would be +//! silently never minted), and its content is pinned by digest, so a re-vendor +//! announces itself in the test diff rather than passing unnoticed. + +use anyhow::{Context, Result, bail}; +use std::borrow::Cow; +use std::path::{Path, PathBuf}; + +/// Environment override for the canon directory. +pub const CANON_ENV: &str = "LAUNCH_SCAFFOLDER_PROVISIONING_CANON"; + +/// Upstream `standards@` provenance of the baked table. +/// The `+local-docstrings` suffix records local shell function documentation; +/// the content digest below pins the complete snapshot, including those comments. +pub const BAKED_CANON_REF: &str = include_str!("../../../../standards/provisioning/CANON"); + +/// Engine files: copied byte-for-byte, owned by realign, and byte-compared by +/// `provision-set check`. `guix/channels.scm` is deliberately absent: it is +/// minted once and then re-pinned per repository by `just toolchain-refresh`, +/// so a byte comparison would fail every refreshed repository. It is checked +/// instead by the commit-pin predicate in `provision-check.sh`. +pub const ENGINE_FILES: &[&str] = &[ + "build/just/provision-check.sh", + "build/just/provision-lib.sh", + "build/just/provision-modes.sh", + "build/just/provision.just", +]; + +/// Every canon file, by path relative to `templates/`, sorted bytewise. +pub static BAKED: &[(&str, &[u8])] = &[ + ( + ".machine_readable/descriptiles/provisioning_praxis.deed.tmpl", + include_bytes!( + "../../../../standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl" + ), + ), + ( + "Justfile.tmpl", + include_bytes!("../../../../standards/provisioning/templates/Justfile.tmpl"), + ), + ( + "README-ai-install.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/README-ai-install.adoc.tmpl"), + ), + ( + "build/just/provision-check.sh", + include_bytes!( + "../../../../standards/provisioning/templates/build/just/provision-check.sh" + ), + ), + ( + "build/just/provision-lib.sh", + include_bytes!("../../../../standards/provisioning/templates/build/just/provision-lib.sh"), + ), + ( + "build/just/provision-modes.sh", + include_bytes!( + "../../../../standards/provisioning/templates/build/just/provision-modes.sh" + ), + ), + ( + "build/just/provision.just", + include_bytes!("../../../../standards/provisioning/templates/build/just/provision.just"), + ), + ( + "docs/AI_INSTALLATION_GUIDE.adoc.tmpl", + include_bytes!( + "../../../../standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl" + ), + ), + ( + "docs/SETUP.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/docs/SETUP.adoc.tmpl"), + ), + ( + "guix/channels.scm", + include_bytes!("../../../../standards/provisioning/templates/guix/channels.scm"), + ), + ( + "guix/guix.scm.cargo.tmpl", + include_bytes!("../../../../standards/provisioning/templates/guix/guix.scm.cargo.tmpl"), + ), + ( + "guix/guix.scm.source.tmpl", + include_bytes!("../../../../standards/provisioning/templates/guix/guix.scm.source.tmpl"), + ), + ( + "guix/manifest.scm.tmpl", + include_bytes!("../../../../standards/provisioning/templates/guix/manifest.scm.tmpl"), + ), + ( + "launcher.sh.tmpl", + include_bytes!("../../../../standards/provisioning/templates/launcher.sh.tmpl"), + ), + ( + "llm-warmup-dev.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/llm-warmup-dev.adoc.tmpl"), + ), + ( + "llm-warmup-maintainer.adoc.tmpl", + include_bytes!( + "../../../../standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl" + ), + ), + ( + "llm-warmup-user.adoc.tmpl", + include_bytes!("../../../../standards/provisioning/templates/llm-warmup-user.adoc.tmpl"), + ), + ( + "mise.toml.tmpl", + include_bytes!("../../../../standards/provisioning/templates/mise.toml.tmpl"), + ), +]; + +/// Where canon bytes come from. +#[derive(Debug, Clone)] +pub enum Canon { + Baked, + Dir(PathBuf), +} + +impl Canon { + /// The explicit override if given, else the baked table. + /// Returns an error if the override has no `templates/` directory; individual + /// files and the `CANON` reference are read only when requested. + pub fn resolve(dir: Option<&Path>) -> Result { + match dir { + None => Ok(Canon::Baked), + Some(d) => { + let t = d.join("templates"); + if !t.is_dir() { + bail!("canon override {} has no templates/ directory", d.display()); + } + Ok(Canon::Dir(d.to_path_buf())) + } + } + } + + /// The trimmed `CANON` reference, conventionally `standards@`. + /// Its format is not validated. Returns an error if an override's `CANON` + /// file cannot be read as UTF-8. + pub fn reference(&self) -> Result { + let raw = match self { + Canon::Baked => BAKED_CANON_REF.to_string(), + Canon::Dir(d) => std::fs::read_to_string(d.join("CANON")) + .with_context(|| format!("reading {}", d.join("CANON").display()))?, + }; + Ok(raw.trim().to_string()) + } + + /// The bytes of a canon file, with `rel` relative to `templates/`. + /// Returns an error if the baked table has no such entry or reading the + /// override file fails. + pub fn file(&self, rel: &str) -> Result> { + match self { + Canon::Baked => BAKED + .iter() + .find(|(p, _)| *p == rel) + .map(|(_, b)| Cow::Borrowed(*b)) + .with_context(|| format!("{rel} is not in the baked provisioning canon")), + Canon::Dir(d) => { + let p = d.join("templates").join(rel); + std::fs::read(&p) + .map(Cow::Owned) + .with_context(|| format!("reading canon file {}", p.display())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Locate the vendored template tree relative to this crate. + fn vendor_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../standards/provisioning/templates") + } + + /// Ensure the baked table lists every vendored template in sorted order. + #[test] + fn the_table_lists_exactly_the_vendored_files() { + let mut on_disk: Vec = walkdir::WalkDir::new(vendor_dir()) + .into_iter() + .map(|e| e.expect("walk vendored canon")) + .filter(|e| e.file_type().is_file()) + .map(|e| { + e.path() + .strip_prefix(vendor_dir()) + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + on_disk.sort(); + let table: Vec = BAKED.iter().map(|(p, _)| p.to_string()).collect(); + assert_eq!( + table, on_disk, + "regenerate BAKED from standards/provisioning/templates" + ); + } + + /// Ensure every engine file is baked and refreshable channel pins are excluded. + #[test] + fn every_engine_file_is_in_the_table() { + for e in ENGINE_FILES { + assert!(BAKED.iter().any(|(p, _)| p == e), "{e} missing from BAKED"); + } + assert!(!ENGINE_FILES.contains(&"guix/channels.scm")); + } + + /// Digest over sorted `path NUL sha256 LF` lines. A re-vendor changes it; + /// bump this pin and `standards/provisioning/CANON` in the same commit. + #[test] + fn the_baked_canon_is_pinned_by_content() { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + for (p, b) in BAKED { + h.update(p.as_bytes()); + h.update([0u8]); + h.update(format!("{:x}", Sha256::digest(b)).as_bytes()); + h.update(b"\n"); + } + let got = format!("{:x}", h.finalize()); + assert_eq!( + got, PINNED_DIGEST, + "the baked provisioning canon changed: confirm standards/provisioning/CANON \ + names the commit it came from, then update this pin in the same commit" + ); + assert!(BAKED_CANON_REF.trim().starts_with("standards@")); + } + + const PINNED_DIGEST: &str = "4df3133905d404d16aeec943fd8be37b601c6037988eb69dc0184ac3167d3dcf"; +} diff --git a/crates/launcher-common/src/provisioning/check.rs b/crates/launcher-common/src/provisioning/check.rs new file mode 100644 index 0000000..f489678 --- /dev/null +++ b/crates/launcher-common/src/provisioning/check.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! `provision-set check`: the engine files must equal the canon byte for byte, +//! and only then is the repository's own `provision-check.sh` trusted to judge +//! the rest. A drifted checker cannot be trusted to check, so drift is terminal. + +use super::canon::{Canon, ENGINE_FILES}; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +/// One engine file that does not match the canon. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Drift { + Missing(String), + Differs(String), +} + +impl std::fmt::Display for Drift { + /// Describe the affected path and whether its engine file is missing or changed. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Drift::Missing(p) => write!(f, "{p}: missing"), + Drift::Differs(p) => write!(f, "{p}: differs from the canon"), + } + } +} + +/// Every engine file under `target` that is absent or not byte-identical to the canon. +/// Returns an empty list when all match. Missing target files are drift; +/// other target read errors and canon lookup/read errors are returned as errors. +pub fn engine_drift(target: &Path, canon: &Canon) -> Result> { + let mut out = Vec::new(); + for rel in ENGINE_FILES { + let want = canon.file(rel)?; + match std::fs::read(target.join(rel)) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + out.push(Drift::Missing(rel.to_string())) + } + Err(e) => { + return Err(e).with_context(|| format!("reading {}", target.join(rel).display())); + } + Ok(have) if have != *want => out.push(Drift::Differs(rel.to_string())), + Ok(_) => {} + } + } + Ok(out) +} + +/// Run the repository's `build/just/provision-check.sh` and return its exit +/// code. A child killed by a signal is a failure (1), never a pass. +/// `dev` passes `--dev`, downgrading unfilled repository-specific slots to +/// warnings. Child output is inherited; failure to start or wait for Bash is +/// returned as an error, whereas a nonzero child exit is returned as a code. +pub fn conformance(target: &Path, dev: bool) -> Result { + let script = target.join("build/just/provision-check.sh"); + let mut cmd = Command::new("bash"); + cmd.arg(&script); + if dev { + cmd.arg("--dev"); + } + cmd.arg(target); + let status = cmd + .status() + .with_context(|| format!("running {}", script.display()))?; + Ok(status.code().unwrap_or(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provisioning::canon::BAKED; + + /// Write the baked engine files into a test directory, creating parent directories. + fn mint_engine(dir: &Path) { + for rel in ENGINE_FILES { + let (_, b) = BAKED.iter().find(|(p, _)| p == rel).unwrap(); + let p = dir.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b).unwrap(); + } + } + + /// Recreate a temporary test directory identified by process ID and case name. + fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("ls-provcheck-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// Verify that freshly copied canon engine files produce no drift findings. + #[test] + fn identical_engine_has_no_drift() { + let d = scratch("clean"); + mint_engine(&d); + assert!(engine_drift(&d, &Canon::Baked).unwrap().is_empty()); + } + + /// Planted positives: one byte changed and one file removed must both be seen. + #[test] + fn a_changed_byte_and_a_missing_file_are_both_drift() { + let d = scratch("dirty"); + mint_engine(&d); + let lib = d.join("build/just/provision-lib.sh"); + let mut b = std::fs::read(&lib).unwrap(); + b.push(b'\n'); + std::fs::write(&lib, b).unwrap(); + std::fs::remove_file(d.join("build/just/provision.just")).unwrap(); + let got = engine_drift(&d, &Canon::Baked).unwrap(); + assert_eq!( + got, + vec![ + Drift::Differs("build/just/provision-lib.sh".into()), + Drift::Missing("build/just/provision.just".into()), + ] + ); + } + + /// A refreshed channels.scm is not drift: it is not an engine file. + #[test] + fn a_repinned_channels_scm_is_not_drift() { + let d = scratch("chan"); + mint_engine(&d); + std::fs::create_dir_all(d.join("guix")).unwrap(); + std::fs::write( + d.join("guix/channels.scm"), + "(list (channel (name 'guix) (commit \"0000000000000000000000000000000000000000\")))\n", + ) + .unwrap(); + assert!(engine_drift(&d, &Canon::Baked).unwrap().is_empty()); + } +} diff --git a/crates/launcher-common/src/provisioning/justfile.rs b/crates/launcher-common/src/provisioning/justfile.rs new file mode 100644 index 0000000..0c8bef2 --- /dev/null +++ b/crates/launcher-common/src/provisioning/justfile.rs @@ -0,0 +1,843 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! Merge the provisioning contract into an existing Justfile +//! (`PROVISIONING-STANDARD.adoc` §1: the Justfile is *merged*, never replaced). +//! +//! * `mod provision 'build/just/provision.just'` is added once. +//! * Every contract verb the Justfile does not define gets a root delegation; +//! a verb it does define (its own `build`, `test`, …) is its override and kept. +//! * A `doctor`, `setup` or `heal` that is estate boilerplate (the generic +//! "Running diagnostics for" family) is removed: the canon verb does that job +//! properly. A custom one is renamed `doctor-local` / `setup-local` / +//! `heal-local`, which the canon verb runs. +//! * Any other contract verb whose body is the unedited RSR template's +//! placeholder (`# TODO: Replace with your ...`) is removed, so the canon +//! verb runs instead of a fake pass. +//! +//! `just` itself is the judge of the result: the merge is kept only when +//! `just --summary` afterwards lists every contract verb and every recipe the +//! file had before (renamed ones under their new name). Otherwise the original +//! bytes are restored and the merge is reported as skipped. + +use super::mint::{Act, VERBS, delegations}; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +/// Text found only in the generic, repo-agnostic doctor/heal bodies that +/// earlier estate sweeps stamped into Justfiles. +pub const BOILERPLATE: &[&str] = &[ + "Running diagnostics for", + "Toolchain Health Check", + "Attempting auto-repair for", + "Heal — Automatic Tool Installation", +]; + +/// Text found only in the unedited RSR template's recipe bodies: a contract +/// verb carrying it (`test:` ... `# TODO: Replace with your test command` ... +/// `@echo "Tests passed!"`) is a placeholder that would shadow the canon verb +/// with a fake pass, not the repository's own override. +pub const PLACEHOLDER: &[&str] = &["# TODO: Replace with your"]; + +/// The verbs a repository may already implement in its own way. +const LOCAL: &[&str] = &["setup", "doctor", "heal"]; + +const MOD_LINE: &str = "mod provision 'build/just/provision.just'"; + +/// Fold several justfiles in `target` into one, which `just` needs before it +/// will run at all ("Multiple candidate justfiles found"). The file with the +/// most recipes is kept (`Justfile` on a tie); each other file's recipes that +/// it lacks are appended to it, and a recipe both define keeps the kept file's +/// body unless that body is a template placeholder or boilerplate and the +/// other's is not. Sources with unmatched top-level content are retained for +/// manual folding; successfully folded files are removed. The result must +/// parse whenever the kept file parsed before, or it is restored, nothing is +/// removed, and the fold is reported as skipped. Returns the kept file's name +/// and one report line per other file. +/// +/// Filesystem errors are propagated and may leave a partial fold. If the kept +/// file cannot be summarised before folding (including when `just` is absent), +/// parsing is not required afterwards. +/// +/// # Panics +/// +/// Panics if `names` is empty. +pub fn fold(target: &Path, names: &[&str]) -> Result<(String, Vec<(String, Act)>)> { + let mut texts = Vec::new(); + for n in names { + let p = target.join(n); + texts + .push(std::fs::read_to_string(&p).with_context(|| format!("reading {}", p.display()))?); + } + let keep = (0..names.len()) + .max_by_key(|&i| (recipe_names(&texts[i]).len(), names[i] == "Justfile")) + .unwrap_or(0); + let kept = names[keep].to_string(); + let parsed_before = summary(target, &kept).is_ok(); + let mut merged = texts[keep].clone(); + let mut notes = Vec::new(); + for (i, n) in names.iter().enumerate().filter(|&(i, _)| i != keep) { + let lines: Vec<&str> = texts[i].lines().collect(); + // Recipes alone cannot preserve settings, variables, imports or aliases. + // Check the whole source before copying any recipes, even if `just` + // could not parse the kept file (or is unavailable). + if lines.iter().any(|l| { + !l.trim().is_empty() + && !l.starts_with([' ', '\t', '#']) + && header_name(l).is_none() + && !merged.lines().any(|have| have == *l) + }) { + notes.push(( + (*n).to_string(), + Act::Skipped(format!( + "top-level content is not in {kept}: fold {n} by hand" + )), + )); + continue; + } + let (mut added, mut shadowed) = (Vec::new(), Vec::new()); + for (h, l) in lines.iter().enumerate() { + let Some(r) = header_name(l) else { continue }; + let have: Vec<&str> = merged.lines().collect(); + let (s, _, e) = span_at(&lines, h); + if let Some((hs, _, he)) = span(&have, r) { + // A template placeholder never beats the repository's own body. + let stub = |b: &str| PLACEHOLDER.iter().chain(BOILERPLATE).any(|m| b.contains(m)); + let theirs = lines[s..e].join("\n"); + if stub(&have[hs..he].join("\n")) && !stub(&theirs) { + let mut out: Vec<&str> = have[..hs].to_vec(); + out.extend(theirs.lines()); + out.extend(&have[he..]); + merged = out.join("\n") + "\n"; + added.push(r.to_string()); + } else { + shadowed.push(r.to_string()); + } + continue; + } + if !merged.ends_with('\n') { + merged.push('\n'); + } + merged.push('\n'); + merged.push_str(&lines[s..e].join("\n")); + merged.push('\n'); + added.push(r.to_string()); + } + let why = match (added.is_empty(), shadowed.is_empty()) { + (true, true) => format!("no recipes; {kept} is the one `just` runs"), + (true, false) => format!("every recipe is already in {kept}: {}", shadowed.join(" ")), + (false, true) => format!("folded into {kept}: {}", added.join(" ")), + (false, false) => format!( + "folded into {kept}: {}; {kept}'s own kept for: {}", + added.join(" "), + shadowed.join(" ") + ), + }; + notes.push(((*n).to_string(), Act::Removed(why))); + } + let path = target.join(&kept); + std::fs::write(&path, &merged).with_context(|| format!("writing {}", path.display()))?; + if parsed_before { + if let Err(e) = summary(target, &kept) { + std::fs::write(&path, &texts[keep])?; + let why = format!("folding them into {kept} breaks it ({e}): fold them by hand"); + let skipped = notes + .into_iter() + .map(|(n, act)| match act { + Act::Skipped(_) => (n, act), + _ => (n, Act::Skipped(why.clone())), + }) + .collect(); + return Ok((kept, skipped)); + } + } + let mut out = Vec::new(); + for (n, act) in notes { + if matches!(act, Act::Removed(_)) { + std::fs::remove_file(target.join(&n)).with_context(|| format!("removing {n}"))?; + } + out.push((n, act)); + } + Ok((kept, out)) +} + +/// Merge into `target/name`. `provision_just` is the canon module's text. +/// Returns `Kept` when already merged, `Replaced` on an accepted merge, or +/// `Skipped` for conflicts or failed validation by `just --summary`. A rejected +/// write is restored before returning `Skipped`; filesystem errors, including +/// restoration failures, are propagated. +pub fn merge(target: &Path, name: &str, provision_just: &str) -> Result { + let path = target.join(name); + let original = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let before = summary(target, name); + + let lines: Vec<&str> = original.lines().collect(); + let mut drop = vec![false; lines.len()]; + let mut renames: Vec<(usize, &str)> = Vec::new(); + let mut replaced = Vec::new(); + let mut renamed = Vec::new(); + + for verb in LOCAL { + let Some((start, header, end)) = span(&lines, verb) else { + if before.as_ref().is_ok_and(|b| b.iter().any(|r| r == verb)) { + return Ok(Act::Skipped(format!( + "`{verb}` comes from an import, not {name}: merge it by hand" + ))); + } + continue; + }; + let deps = lines[header].split_once(':').map_or("", |(_, d)| d); + if deps.contains("provision::") { + continue; // already a delegation + } + let body = lines[header..end].join("\n"); + if BOILERPLATE.iter().any(|m| body.contains(m)) { + drop[start..end].iter_mut().for_each(|d| *d = true); + replaced.push(*verb); + } else { + let local = format!("{verb}-local"); + if before.as_ref().is_ok_and(|b| b.contains(&local)) || span(&lines, &local).is_some() { + return Ok(Act::Skipped(format!( + "a custom `{verb}` and a `{local}` both exist: merge them by hand" + ))); + } + renames.push((header, verb)); + renamed.push(*verb); + } + } + + for verb in VERBS.iter().filter(|v| !LOCAL.contains(v)) { + let Some((start, header, end)) = span(&lines, verb) else { + continue; + }; + let body = lines[header..end].join("\n"); + if PLACEHOLDER.iter().any(|m| body.contains(m)) { + drop[start..end].iter_mut().for_each(|d| *d = true); + replaced.push(*verb); + } + } + + if lines.iter().any(|l| header_name(l) == Some("provision")) { + return Ok(Act::Skipped(format!( + "{name} has its own `provision` recipe, which clashes with `mod provision`: rename it by hand" + ))); + } + + // A file `just` cannot parse is merged only when a mechanical repair is + // available, each undoing damage an earlier estate sweep did: + // * boilerplate removal (above); + // * lines left at column 0 inside a shebang body are re-indented (the body + // is one script, so the indent changes nothing it runs); + // * column-0 `//` comments become `#`; + // * a duplicate recipe whose body is the Nix sweep's dead `flake.guix` + // fallback is dropped (`guix develop` and `flake.guix` do not exist). + // `just` judges the result below. + let mut indent = vec![false; lines.len()]; + let mut slashes = vec![false; lines.len()]; + let mut repaired = !replaced.is_empty(); + if before.is_err() { + for (i, l) in lines.iter().enumerate() { + let Some(name) = header_name(l) else { + continue; + }; + if drop[i] { + continue; + } + let (start, h, end) = span_at(&lines, i); + let dup = lines + .iter() + .filter(|o| header_name(o) == Some(name)) + .count() + > 1; + if dup && lines[h..end].iter().any(|b| b.contains("flake.guix")) { + drop[start..end].iter_mut().for_each(|d| *d = true); + repaired = true; + continue; + } + if !lines + .get(h + 1) + .is_some_and(|b| b.trim_start().starts_with("#!")) + { + continue; + } + for j in h + 1..end { + if !lines[j].is_empty() && !lines[j].starts_with([' ', '\t']) { + indent[j] = true; + repaired = true; + } + } + } + for (i, l) in lines.iter().enumerate() { + if l.starts_with("//") && !indent[i] { + slashes[i] = true; + repaired = true; + } + } + } + if let Err(e) = &before + && !repaired + { + return Ok(Act::Skipped(format!( + "{name} does not parse, and no mechanical repair applies: {e}" + ))); + } + + let mut out = String::with_capacity(original.len() + 2048); + for (i, line) in lines.iter().enumerate() { + if drop[i] { + continue; + } + match renames.iter().find(|(h, _)| *h == i) { + Some((_, verb)) => { + let at = line.find(verb).unwrap_or(0); + out.push_str(&line[..at + verb.len()]); + out.push_str("-local"); + out.push_str(&line[at + verb.len()..]); + } + None => { + if indent[i] { + out.push_str(" "); + } + match line.strip_prefix("//").filter(|_| slashes[i]) { + Some(rest) => { + out.push('#'); + out.push_str(rest); + } + None => out.push_str(line), + } + } + } + out.push('\n'); + } + // Removing a block can leave a run of blank lines; keep at most one. + while out.contains("\n\n\n") { + out = out.replace("\n\n\n", "\n\n"); + } + + let defined: Vec = recipe_names(&out); + let has_mod = out.lines().any(|l| { + let l = l.trim_start(); + (l.starts_with("mod provision") || l.starts_with("mod? provision")) + && l["mod".len()..] + .trim_start_matches('?') + .trim_start() + .starts_with("provision") + }); + let added: Vec<&str> = VERBS + .iter() + .copied() + .filter(|v| !defined.iter().any(|d| d == v)) + .collect(); + if has_mod && added.is_empty() && replaced.is_empty() && renamed.is_empty() { + return Ok(Act::Kept("already merged".into())); + } + let block = delegations(provision_just, &defined); + if !out.ends_with("\n\n") { + out.push('\n'); + } + out.push_str("# --- Provisioning contract (PROVISIONING-STANDARD §2) ---------------------\n"); + out.push_str( + "# Merged by `launch-scaffolder provision-set`. A recipe defined above overrides\n", + ); + out.push_str("# the canon one; doctor/setup/heal run this repo's *-local recipes.\n"); + if !has_mod { + out.push_str(MOD_LINE); + out.push_str("\n\n"); + } + if !block.is_empty() { + out.push_str(&block); + out.push('\n'); + } + + std::fs::write(&path, &out).with_context(|| format!("writing {}", path.display()))?; + let after = summary(target, name); + let lost = verify(before.as_deref().ok(), after.as_deref(), &renamed); + if let Some(why) = lost { + std::fs::write(&path, &original) + .with_context(|| format!("restoring {}", path.display()))?; + return Ok(Act::Skipped(format!( + "merge rejected by `just --summary`, original restored: {why}" + ))); + } + + let mut what = Vec::new(); + if !added.is_empty() { + what.push(format!("{} delegation(s) added", added.len())); + } + if !replaced.is_empty() { + what.push(format!( + "boilerplate {} replaced by the canon", + replaced.join("/") + )); + } + if !renamed.is_empty() { + what.push(format!( + "custom {} kept as {}", + renamed.join("/"), + renamed + .iter() + .map(|v| format!("{v}-local")) + .collect::>() + .join("/") + )); + } + if before.is_err() { + what.push("repairs a Justfile that did not parse".into()); + } + Ok(Act::Replaced(what.join("; "))) +} + +/// Why the merged file is unacceptable, or `None`. +fn verify( + before: Option<&[String]>, + after: Result<&[String], &anyhow::Error>, + renamed: &[&str], +) -> Option { + let after = match after { + Ok(a) => a, + Err(e) => return Some(format!("the merged file does not parse: {e}")), + }; + let has = |r: &str| after.iter().any(|a| a == r); + let mut missing: Vec = VERBS + .iter() + .filter(|v| !has(v)) + .map(|v| v.to_string()) + .collect(); + missing.extend( + VERBS + .iter() + .filter(|v| !has(&format!("provision::{v}"))) + .map(|v| format!("provision::{v}")), + ); + for r in before.unwrap_or_default() { + let now = if renamed.contains(&r.as_str()) { + format!("{r}-local") + } else { + r.clone() + }; + if !has(&now) { + missing.push(now); + } + } + (!missing.is_empty()).then(|| format!("missing {}", missing.join(", "))) +} + +/// `just --summary` for the Justfile, as recipe names. +fn summary(target: &Path, name: &str) -> Result> { + let o = Command::new("just") + .arg("--justfile") + .arg(target.join(name)) + .arg("--working-directory") + .arg(target) + .arg("--summary") + .output() + .context("running `just --summary` (is just >= 1.42 on PATH?)")?; + if !o.status.success() { + let err = String::from_utf8_lossy(&o.stderr); + anyhow::bail!("{}", err.lines().next().unwrap_or("just failed").trim()); + } + Ok(String::from_utf8_lossy(&o.stdout) + .split_whitespace() + .map(str::to_string) + .collect()) +} + +/// The recipe name a column-0 line declares, if it is a recipe header. +fn header_name(line: &str) -> Option<&str> { + let l = line.strip_prefix('@').unwrap_or(line); + let end = l.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))?; + let name = &l[..end]; + if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') { + return None; + } + if matches!( + name, + "set" + | "alias" + | "export" + | "import" + | "mod" + | "if" + | "else" + | "fi" + | "for" + | "done" + | "then" + ) { + return None; + } + // Parameters are words, optionally `=default` (quoted or bare), then `:`. + let mut rest = &l[end..]; + loop { + rest = rest.trim_start_matches([' ', '\t']); + if let Some(r) = rest.strip_prefix(':') { + return (!r.starts_with('=')).then_some(name); + } + let r = rest.trim_start_matches(['+', '*', '$']); + let w = r + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')) + .unwrap_or(r.len()); + if w == 0 { + return None; + } + rest = &r[w..]; + if let Some(r) = rest.strip_prefix('=') { + rest = match r.chars().next() { + Some(q @ ('"' | '\'')) => &r[1..][r[1..].find(q)? + 1..], + _ => &r[r.find([' ', ':']).unwrap_or(r.len())..], + }; + } + } +} + +/// A column-0 line that starts something new: a header or a directive. +fn starts_item(line: &str) -> bool { + header_name(line).is_some() + || ["set ", "alias ", "export ", "import", "mod ", "mod? ", "["] + .iter() + .any(|p| line.starts_with(p)) +} + +/// `(first line of the doc comment, header line, end)` of recipe `name`. +/// +/// The body is everything up to the next column-0 header or directive (and +/// that item's own doc comment), so a body with stray unindented lines, which +/// `just` rejects, is still taken whole. +fn span(lines: &[&str], name: &str) -> Option<(usize, usize, usize)> { + let header = lines.iter().position(|l| header_name(l) == Some(name))?; + Some(span_at(lines, header)) +} + +/// [`span`] of the recipe whose header is line `header`. +fn span_at(lines: &[&str], header: usize) -> (usize, usize, usize) { + let mut start = header; + while start > 0 && (lines[start - 1].starts_with('#') || lines[start - 1].starts_with('[')) { + start -= 1; + } + let mut end = header + 1; + while end < lines.len() && !starts_item(lines[end]) { + end += 1; + } + // Trailing comments and blanks before the next item belong to it. + while end > header + 1 { + let l = lines[end - 1]; + if l.trim().is_empty() || l.starts_with('#') { + end -= 1; + } else { + break; + } + } + (start, header, end) +} + +/// Collect recipe names in source order, ignoring lines without recipe headers. +fn recipe_names(text: &str) -> Vec { + text.lines() + .filter_map(header_name) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Verify recipe header parsing, including parameters, while rejecting directives and bodies. + #[test] + fn headers_are_recognised_and_directives_are_not() { + assert_eq!(header_name("build:"), Some("build")); + assert_eq!(header_name("@doctor: build"), Some("doctor")); + assert_eq!(header_name("release version:"), Some("release")); + assert_eq!(header_name("ai-warmup who=\"user: x\":"), Some("ai-warmup")); + assert_eq!(header_name("serve port='8080' *args:"), Some("serve")); + assert_eq!(header_name("x := \"y\""), None); + assert_eq!(header_name("set shell := [\"bash\"]"), None); + assert_eq!(header_name("if command -v x; then"), None); + assert_eq!(header_name(" echo hi:"), None); + assert_eq!(header_name("# doctor:"), None); + } + + /// Verify recipe spans include damaged body lines but preserve the next recipe documentation. + #[test] + fn a_span_takes_stray_unindented_lines_and_leaves_the_next_doc() { + let src = "# Diagnose\ndoctor:\n #!/usr/bin/env bash\n a\n# Optional tools\nif x; then\n b\nfi\n c\n\n# Repair\nheal:\n d\n"; + let lines: Vec<&str> = src.lines().collect(); + assert_eq!(span(&lines, "doctor"), Some((0, 1, 9))); + assert_eq!(span(&lines, "heal"), Some((10, 11, 13))); + } + + /// Create a temporary repository containing the baked engine and the supplied Justfile. + fn repo(justfile: &str) -> std::path::PathBuf { + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let d = std::env::temp_dir().join(format!("justfile-test-{}-{n}", std::process::id())); + let canon = crate::provisioning::canon::Canon::Baked; + for rel in crate::provisioning::canon::ENGINE_FILES { + let p = d.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, canon.file(rel).unwrap()).unwrap(); + } + std::fs::write(d.join("Justfile"), justfile).unwrap(); + d + } + + /// Decode the baked provisioning Just module for merge and fold tests. + fn provision_just() -> String { + String::from_utf8( + crate::provisioning::canon::Canon::Baked + .file("build/just/provision.just") + .unwrap() + .into_owned(), + ) + .unwrap() + } + + const BROKEN: &str = "# Build\nbuild:\n cargo build\n\n# Self-diagnostic\ndoctor:\n #!/usr/bin/env bash\n echo \"Running diagnostics for x\"\nif command -v y >/dev/null; then\n echo ok\nfi\n\n# Help\nhelp-me:\n #!/usr/bin/env bash\n echo \"\"\necho \"FIRST TIME SETUP:\"\n"; + + /// Verify identical Justfiles collapse to one without changing the retained contents. + #[test] + fn identical_justfiles_fold_to_one_and_the_copy_is_removed() { + let src = "# Build\nbuild:\n echo b\n"; + let d = repo(src); + std::fs::write(d.join("justfile"), src).unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "Justfile"); + assert_eq!(acts.len(), 1); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.contains("already in Justfile")), + "{acts:?}" + ); + assert!(!d.join("justfile").exists()); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// The merge and fold tests run `just --summary`, so need `just` >= 1.42 on PATH. + /// rust-ci skips this module by name; launcher-artefacts runs and counts it. + mod needs_just { + use super::*; + + /// Verify folding retains unmatched top-level content even when the kept file cannot parse. + #[test] + fn folding_retains_unmatched_top_level_content_even_when_kept_file_is_broken() { + for kept in ["build:\n echo b\ntest:\n echo t\n", BROKEN] { + for directive in [ + "x := 'value'", + "set dotenv-load", + "alias check := lint", + "import 'extra.just'", + "mod extra 'extra.just'", + ] { + let d = repo(kept); + assert_eq!(summary(&d, "Justfile").is_err(), kept == BROKEN); + // Put the directive after a recipe to catch partial folding. + let source = format!("lint:\n echo lint\n\n{directive}\n"); + std::fs::write(d.join("justfile"), &source).unwrap(); + let (_, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert!( + matches!(&acts[0].1, Act::Skipped(w) if w.contains("by hand")), + "{directive}: {acts:?}" + ); + assert_eq!(std::fs::read_to_string(d.join("justfile")).unwrap(), source); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), kept); + std::fs::remove_dir_all(&d).unwrap(); + } + } + } + + /// Verify folding removes sources with shared directives but retains unmatched settings. + #[test] + fn folding_removes_only_safe_sources_and_accepts_shared_directives() { + let d = repo("set dotenv-load\nbuild:\n echo b\ntest:\n echo t\n"); + let unsafe_source = "set export\n"; + std::fs::write(d.join("justfile"), unsafe_source).unwrap(); + std::fs::write( + d.join(".justfile"), + "# Shared setting\nset dotenv-load\n\nlint:\n echo lint\n", + ) + .unwrap(); + let (_, acts) = fold(&d, &["Justfile", "justfile", ".justfile"]).unwrap(); + assert!(matches!(acts[0].1, Act::Skipped(_)), "{acts:?}"); + assert!(matches!(acts[1].1, Act::Removed(_)), "{acts:?}"); + assert_eq!( + std::fs::read_to_string(d.join("justfile")).unwrap(), + unsafe_source + ); + assert!(!d.join(".justfile").exists()); + assert_eq!(summary(&d, "Justfile").unwrap(), ["build", "lint", "test"]); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Verify merging repairs boilerplate, preserves custom verbs, and is idempotent. + #[test] + fn boilerplate_is_replaced_and_a_broken_file_repaired() { + let d = repo(BROKEN); + assert!( + summary(&d, "Justfile").is_err(), + "the control must not parse" + ); + let act = merge(&d, "Justfile", &provision_just()).unwrap(); + assert!(matches!(act, Act::Replaced(_)), "{act}"); + let after = summary(&d, "Justfile").unwrap(); + for v in VERBS { + assert!(after.iter().any(|r| r == v), "{v} missing"); + } + assert!(after.iter().any(|r| r == "help-me") && after.iter().any(|r| r == "build")); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(!text.contains("Running diagnostics for")); + assert!(text.contains(" echo \"FIRST TIME SETUP:\"")); + assert_eq!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Kept("already merged".into()) + ); + } + + /// Verify a custom doctor is preserved as doctor-local and an existing twin prevents merging. + #[test] + fn a_custom_doctor_becomes_doctor_local() { + let d = repo("doctor:\n @echo mine\n"); + merge(&d, "Justfile", &provision_just()).unwrap(); + let after = summary(&d, "Justfile").unwrap(); + assert!( + after.iter().any(|r| r == "doctor-local") && after.iter().any(|r| r == "doctor") + ); + let clash = repo("doctor:\n @echo a\ndoctor-local:\n @echo b\n"); + assert!(matches!( + merge(&clash, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + } + + /// Verify canon delegation replaces a placeholder test recipe while preserving a real benchmark. + #[test] + fn a_template_placeholder_verb_is_replaced_and_a_real_one_kept() { + let d = repo( + "test *args:\n @echo \"Running tests...\"\n # TODO: Replace with your test command\n @echo \"Tests passed!\"\n\nbench:\n cargo bench\n", + ); + let act = merge(&d, "Justfile", &provision_just()).unwrap(); + assert!( + matches!(act, Act::Replaced(ref w) if w.contains("test")), + "{act}" + ); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(!text.contains("Tests passed!"), "the placeholder must go"); + assert!( + text.contains("test: provision::test"), + "the canon verb must take over" + ); + assert!( + text.contains(" cargo bench"), + "a real override must stay" + ); + assert!(!text.contains("bench: provision::bench")); + } + + /// Verify known syntax damage is repaired and a conflicting provision recipe blocks merging. + #[test] + fn sweep_damage_is_repaired_and_a_provision_recipe_refused() { + let src = "// SPDX-License-Identifier: MPL-2.0\n\nguix-shell:\n guix shell -D -f guix.scm\n\n# fallback\nguix-shell:\n @if [ -f \"flake.guix\" ]; then guix develop; fi\n"; + let d = repo(src); + assert!( + summary(&d, "Justfile").is_err(), + "the control must not parse" + ); + assert!(matches!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Replaced(_) + )); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!(text.starts_with("# SPDX") && !text.contains("flake.guix")); + assert!(text.contains("guix shell -D -f guix.scm")); + let clash = repo("provision:\n @echo mine\n"); + assert!(matches!( + merge(&clash, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + } + + /// Verify an unsuccessful repair preserves the original Justfile bytes. + #[test] + fn an_unrepairable_file_is_left_byte_identical() { + let src = "build:\n cargo build\nthis is not just syntax\n"; + let d = repo(src); + assert!(matches!( + merge(&d, "Justfile", &provision_just()).unwrap(), + Act::Skipped(_) + )); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), src); + } + + /// Verify folding prefers a real recipe over a placeholder with the same name. + #[test] + fn a_real_body_replaces_a_template_placeholder_of_the_same_name() { + // The kept file is the bigger, unedited RSR template; the other file holds + // the recipe the author actually wrote. + let d = repo( + "# Build\nbuild:\n # TODO: Replace with your build command\n @echo built\n\nci:\n echo ci\n\ndocs:\n echo d\n", + ); + std::fs::write(d.join("justfile"), "build:\n cargo build --release\n").unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "Justfile"); + let text = std::fs::read_to_string(d.join("Justfile")).unwrap(); + assert!( + text.contains("cargo build --release") && !text.contains("TODO"), + "{text}" + ); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.starts_with("folded into Justfile: build")), + "{acts:?}" + ); + assert_eq!(summary(&d, "Justfile").unwrap(), ["build", "ci", "docs"]); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Verify folding retains the file with more recipes and resolves duplicate recipe names. + #[test] + fn the_richer_justfile_is_kept_and_the_others_recipes_join_it() { + // action-trust-layers' shape: the real recipes are in the lowercase file. + let d = repo("# Help\nhelp:\n echo h\n"); + std::fs::write( + d.join("justfile"), + "# Build\nbuild:\n cargo build\n\n# Test\ntest:\n cargo test\n\nhelp:\n echo other\n", + ) + .unwrap(); + let (kept, acts) = fold(&d, &["Justfile", "justfile"]).unwrap(); + assert_eq!(kept, "justfile"); + let text = std::fs::read_to_string(d.join("justfile")).unwrap(); + assert_eq!(text.matches("help:").count(), 1, "{text}"); + assert!( + text.contains("cargo build") && text.contains("echo other"), + "{text}" + ); + assert!(!d.join("Justfile").exists()); + assert!( + matches!(&acts[0].1, Act::Removed(w) if w.contains("help")), + "{acts:?}" + ); + assert_eq!(summary(&d, "justfile").unwrap(), ["build", "help", "test"]); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Verify a fold that introduces invalid syntax restores the retained file and keeps the source. + #[test] + fn a_fold_that_would_break_the_kept_file_is_undone() { + // `x` is a variable in the kept file; appending a recipe that reassigns it + // is a parse error, so nothing may be removed. + let kept = "x := \"1\"\n\nbuild:\n echo {{x}}\n\ntest:\n echo t\n"; + let d = repo(kept); + std::fs::write(d.join(".justfile"), "lint:\n echo {{y}}\n").unwrap(); + let (k, acts) = fold(&d, &["Justfile", ".justfile"]).unwrap(); + assert_eq!(k, "Justfile"); + assert!( + matches!(&acts[0].1, Act::Skipped(w) if w.contains("by hand")), + "{acts:?}" + ); + assert!(d.join(".justfile").exists()); + assert_eq!(std::fs::read_to_string(d.join("Justfile")).unwrap(), kept); + std::fs::remove_dir_all(&d).unwrap(); + } + } +} diff --git a/crates/launcher-common/src/provisioning/licence.rs b/crates/launcher-common/src/provisioning/licence.rs new file mode 100644 index 0000000..e434a7b --- /dev/null +++ b/crates/launcher-common/src/provisioning/licence.rs @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! Classify a repository's licence from its own licence text, as +//! `PROVISIONING-STANDARD.adoc` §6 requires before a single file is minted. +//! +//! Minted files carry an SPDX header from birth, so the header must match the +//! repository's classification in `3-practice/LICENCE-POLICY.adoc`. Evidence is +//! the repository's licence files and nothing else: when they do not settle the +//! question the answer is a refusal, never a guess. + +use std::path::Path; + +/// LICENCE-POLICY Rule 2: the only repositories that may carry PMPL. A PMPL +/// declaration anywhere else is drift, so it is refused, not minted. +pub const PMPL_REGISTER: &[&str] = &[ + "palimpsest-license", + "palimpsest-plasma", + "consent-aware-web", + "insolvency-tycoon", + "sim-public-relations", +]; + +/// Repositories outside the provisioning set (standard §1): `007` is all +/// rights reserved and the vaults are private stores. +pub const OUT_OF_SCOPE: &[&str] = &["007", "dev-notes-vault", "memory-vault"]; + +/// A classified repository: the SPDX identifiers minted files carry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Licence { + /// `__LICENSE__`: code, config and scripts. + pub code: &'static str, + /// `__DOC_LICENSE__`: prose documents. + pub doc: &'static str, + /// The Guix `license` field. + pub guix: &'static str, + /// Why `doc` was chosen, when no ruling names it. The campaign puts this + /// in the PR body so the owner can ratify or correct it. + pub unratified: Option<&'static str>, +} + +const MPL: Licence = Licence { + code: "MPL-2.0", + doc: "CC-BY-SA-4.0", + guix: "license:mpl2.0", + unratified: None, +}; +const AGPL: Licence = Licence { + code: "AGPL-3.0-or-later", + doc: "AGPL-3.0-or-later", + guix: "license:agpl3+", + unratified: Some( + "LICENCE-POLICY Rule 3 names no prose licence for son-shared repositories, so docs carry the code licence", + ), +}; +// Guix has no PMPL; LICENCE-POLICY Rule 2 makes MPL-2.0 its legal fallback. +const PMPL: Licence = Licence { + code: "PMPL-1.0-or-later", + doc: "PMPL-1.0-or-later", + guix: "license:mpl2.0", + unratified: Some( + "LICENCE-POLICY Rule 2 names no prose licence for PMPL repositories, so docs carry the code licence", + ), +}; + +/// Why a repository was not classified. Each is a ledger line, not an error to +/// work around. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + OutOfScope(String), + NoLicenceFile, + Unrecognised(Vec), + Ambiguous(Vec<&'static str>), + PmplOutsideRegister(String), +} + +impl std::fmt::Display for Refusal { + /// Format the licence refusal with its repository, evidence, or policy reason. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refusal::OutOfScope(r) => { + write!(f, "{r} is outside the provisioning set (standard §1)") + } + Refusal::NoLicenceFile => write!(f, "no LICENSE, LICENCE, COPYING or LICENSES/ file"), + Refusal::Unrecognised(files) => write!( + f, + "licence text in {} is not MPL-2.0, AGPL-3.0 or PMPL (third-party or fork?)", + files.join(", ") + ), + Refusal::Ambiguous(found) => { + write!(f, "licence files disagree: {}", found.join(" and ")) + } + Refusal::PmplOutsideRegister(r) => write!( + f, + "{r} declares PMPL but is not in the LICENCE-POLICY Rule 2 register (drift: flag, do not mint)" + ), + } + } +} + +impl std::error::Error for Refusal {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Signal { + Mpl, + Agpl, + Pmpl, +} + +impl Signal { + /// Return the licence label used when reporting conflicting classification signals. + fn name(self) -> &'static str { + match self { + Signal::Mpl => "MPL-2.0", + Signal::Agpl => "AGPL-3.0", + Signal::Pmpl => "PMPL", + } + } +} + +/// What one licence text declares. PMPL is derived from MPL and names it, so a +/// Palimpsest text is PMPL however often it mentions Mozilla. MPL-2.0 in turn +/// names the GNU Affero GPL among its Secondary Licenses (§1.12), so MPL is +/// tested before AGPL; the AGPL text never names Mozilla. +fn signal(text: &str) -> Option { + let t = text.to_ascii_lowercase(); + if t.contains("palimpsest") || t.contains("pmpl-1.0") { + Some(Signal::Pmpl) + } else if (t.contains("mozilla public license") && t.contains("2.0")) || t.contains("mpl-2.0") { + Some(Signal::Mpl) + } else if t.contains("gnu affero general public license") || t.contains("agpl-3.0") { + Some(Signal::Agpl) + } else { + None + } +} + +/// Recognise root licence filenames by case-insensitive LICENSE, LICENCE, or COPYING prefixes. +fn is_licence_name(name: &str) -> bool { + let n = name.to_ascii_uppercase(); + n.starts_with("LICENSE") || n.starts_with("LICENCE") || n.starts_with("COPYING") +} + +/// Classify `target`, whose repository name (without its owner) is `repo`. +/// Root licence files take precedence over the `LICENSES/` fallback. Returns +/// code, prose and Guix licence identifiers, with an unratified prose note +/// where applicable. +/// +/// Refuses excluded repositories, missing or unrecognised licence evidence, +/// conflicting recognised licences, and PMPL outside the register. Unreadable +/// directories contribute no files; unreadable or non-UTF-8 files contribute +/// no recognised signal. Unrecognised files do not veto a recognised licence. +pub fn classify(target: &Path, repo: &str) -> Result { + if OUT_OF_SCOPE.contains(&repo) { + return Err(Refusal::OutOfScope(repo.to_string())); + } + // The root licence files are the declaration. LICENSES/ (REUSE layout) is + // read only when the root has none, and there its CC-BY-SA text is the prose + // licence, not a competing code licence. + let mut files: Vec = read_dir_sorted(target) + .into_iter() + .filter(|p| { + p.is_file() + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(is_licence_name) + }) + .collect(); + if files.is_empty() { + files = read_dir_sorted(&target.join("LICENSES")) + .into_iter() + .filter(|p| p.is_file()) + .collect(); + } + if files.is_empty() { + return Err(Refusal::NoLicenceFile); + } + let mut found: Vec = Vec::new(); + let mut unread = Vec::new(); + for f in &files { + let text = std::fs::read_to_string(f).unwrap_or_default(); + match signal(&text) { + Some(s) => { + if !found.contains(&s) { + found.push(s); + } + } + None => unread.push( + f.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + ), + } + } + match found.as_slice() { + [] => Err(Refusal::Unrecognised(unread)), + [Signal::Mpl] => Ok(MPL), + [Signal::Agpl] => Ok(AGPL), + [Signal::Pmpl] if PMPL_REGISTER.contains(&repo) => Ok(PMPL), + [Signal::Pmpl] => Err(Refusal::PmplOutsideRegister(repo.to_string())), + many => Err(Refusal::Ambiguous(many.iter().map(|s| s.name()).collect())), + } +} + +/// Return sorted directory entries, omitting unreadable entries and treating read failures as empty. +fn read_dir_sorted(dir: &Path) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(dir) + .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect()) + .unwrap_or_default(); + v.sort(); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a temporary licence fixture with the supplied relative paths and file contents. + fn repo(files: &[(&str, &str)]) -> std::path::PathBuf { + let n = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let d = std::env::temp_dir().join(format!("licence-test-{}-{n}", std::process::id())); + for (p, body) in files { + let f = d.join(p); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(f, body).unwrap(); + } + std::fs::create_dir_all(&d).unwrap(); + d + } + + const MPL_TEXT: &str = + "Mozilla Public License Version 2.0\n==================================\n"; + const AGPL_TEXT: &str = " GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n"; + const PMPL_TEXT: &str = + "Palimpsest-MPL License 1.0\nderived from the Mozilla Public License Version 2.0\n"; + + /// Verify MPL code is paired with the required CC-BY-SA documentation licence. + #[test] + fn mpl_repo_gets_cc_by_sa_docs() { + let d = repo(&[("LICENSE", MPL_TEXT)]); + assert_eq!(classify(&d, "aerie"), Ok(MPL)); + } + + /// Verify the MPL secondary-licence reference to AGPL does not change classification. + #[test] + fn full_mpl_text_naming_the_affero_gpl_is_still_mpl() { + // Re-wrapped onto single spaces, as a reflowed copy would be, so the + // AGPL name is no longer split across a line break. + let full = include_str!("../../../../LICENSES/MPL-2.0.txt") + .split_whitespace() + .collect::>() + .join(" "); + assert!( + full.contains("GNU Affero General Public License"), + "the control must name AGPL" + ); + let d = repo(&[("LICENSE", full.as_str())]); + assert_eq!(classify(&d, "aerie"), Ok(MPL)); + } + + /// Verify AGPL classification includes the Guix identifier and an unratified prose-licence note. + #[test] + fn agpl_control_is_classified_agpl_and_flagged() { + let d = repo(&[("LICENSE.txt", AGPL_TEXT)]); + let l = classify(&d, "idaptik").unwrap(); + assert_eq!(l.code, "AGPL-3.0-or-later"); + assert_eq!(l.guix, "license:agpl3+"); + assert!(l.unratified.is_some()); + } + + /// Verify PMPL is accepted for registered repositories and refused elsewhere. + #[test] + fn pmpl_only_inside_the_register() { + let d = repo(&[("LICENSE", PMPL_TEXT)]); + assert_eq!( + classify(&d, "insolvency-tycoon").unwrap().code, + "PMPL-1.0-or-later" + ); + assert_eq!( + classify(&d, "aerie"), + Err(Refusal::PmplOutsideRegister("aerie".into())) + ); + } + + /// Verify missing, unrecognised, conflicting, and excluded licences yield distinct refusals. + #[test] + fn refusals_name_their_reason() { + assert_eq!(classify(&repo(&[]), "x"), Err(Refusal::NoLicenceFile)); + assert_eq!( + classify(&repo(&[("LICENSE", "MIT License\n")]), "x"), + Err(Refusal::Unrecognised(vec!["LICENSE".into()])) + ); + assert_eq!( + classify(&repo(&[("LICENSE", MPL_TEXT), ("COPYING", AGPL_TEXT)]), "x"), + Err(Refusal::Ambiguous(vec!["AGPL-3.0", "MPL-2.0"])) + ); + assert_eq!( + classify(&repo(&[("LICENSE", MPL_TEXT)]), "007"), + Err(Refusal::OutOfScope("007".into())) + ); + } + + /// Verify the REUSE layout supplies the code licence without treating prose licensing as a conflict. + #[test] + fn reuse_layout_is_read_when_the_root_has_no_licence() { + let d = repo(&[ + ("LICENSES/MPL-2.0.txt", MPL_TEXT), + ( + "LICENSES/CC-BY-SA-4.0.txt", + "Creative Commons Attribution-ShareAlike 4.0\n", + ), + ]); + assert_eq!(classify(&d, "x"), Ok(MPL)); + } +} diff --git a/crates/launcher-common/src/provisioning/mint.rs b/crates/launcher-common/src/provisioning/mint.rs new file mode 100644 index 0000000..cf68f86 --- /dev/null +++ b/crates/launcher-common/src/provisioning/mint.rs @@ -0,0 +1,1438 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! `provision-set mint` (and `realign`, which is the same operation): write a +//! repository's provisioning set from the canon, following the ownership rules +//! of `PROVISIONING-STANDARD.adoc` §1. +//! +//! * Engine files are copied byte for byte and always realigned. +//! * Minted files are created when missing and replaced only while they are +//! still stubs, or when the set was inherited from another repository (the +//! deed's `:repo` names someone else). Once filled, the repository owns them. +//! * A `mise.toml` that pins a banned tool is replaced, carrying over its other +//! `[tools]` entries. +//! +//! Every fact about the repository (languages, tools, Guix specs, where the +//! files go) comes from the engine's own `provision-lib.sh`, run against the +//! target, so the generator and `just doctor` can never disagree about them. +//! Only `.tmpl` files are slot-filled; `__SPEC_*__` slots are left for the +//! repository-specific pass (standard §5). + +use super::canon::{Canon, ENGINE_FILES}; +use super::licence::{self, Licence}; +use anyhow::{Context, Result, bail}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// `__COPYRIGHT_HOLDER__` for every minted file. +pub const HOLDER: &str = "Jonathan D.A. Jewell (hyperpolymath) "; + +/// The contract verbs (standard §2): `just ` must work in every repository. +pub const VERBS: &[&str] = &[ + "setup", + "doctor", + "heal", + "dev-shell", + "toolchain-refresh", + "ai-setup", + "ai-warmup", + "eval", + "config-show", + "opsm", + "build", + "test", + "bench", + "lint", + "fmt", + "fmt-check", + "run", + "deps", +]; + +const ARCHETYPES: &[&str] = &["app", "library", "tool", "theory", "docs"]; +const DEED: &str = ".machine_readable/descriptiles/provisioning_praxis.deed"; +const LIB: &str = "build/just/provision-lib.sh"; +const WRAP: usize = 80; + +/// The repository mise configs, lowest precedence first: mise merges them and +/// a later file's pin wins (measured with `mise ls --current`). These are the +/// only mise config paths tracked anywhere in the estate's local clones +/// (2026-10-01); `provision-lib.sh` `mise_toml_tools` reads the same three. +const MISE_PRECEDENCE: [&str; 3] = [".tool-versions", "mise.toml", ".mise.toml"]; +/// The configs mint folds into `mise.toml` and removes. +const SECONDARY_MISE: [&str; 2] = [".tool-versions", ".mise.toml"]; +/// Version floors for canon tools: a carried pin below one is raised to +/// `latest`. Mirrors the deed's `:just-floor`; a test keeps the two equal. +const TOOL_FLOORS: &[(&str, &str)] = &[("just", "1.42.0")]; + +#[derive(Debug, Default, Clone)] +pub struct Options { + /// `owner/name`; default: the `origin` remote. + pub repo: Option, + /// Overrides the deed's archetype. + pub archetype: Option, + /// `__YEAR__`; default: `SOURCE_DATE_EPOCH`, else the current year. + pub year: Option, + /// Skip the steps that need the network or Guix (`mise lock`, + /// `build/guix/crates.scm`); the files they write are then left for + /// `just toolchain-refresh`. + pub offline: bool, +} + +/// What happened to one file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Act { + Created, + Replaced(String), + Kept(String), + Skipped(String), + /// A secondary file the canon folds into another (`.mise.toml` and + /// `.tool-versions` into `mise.toml`, `justfile` into `Justfile`). + Removed(String), + /// An external step (`mise lock`, `guix import crate`) did not produce a + /// valid file: a ledger line, and the CLI exits [`EXIT_EXTERNAL`]. + Failed(String), +} + +/// Exit code when the set was written but an external step failed. +pub const EXIT_EXTERNAL: i32 = 4; + +impl std::fmt::Display for Act { + /// Format a file action, including the reason for replacement, retention, removal, or failure. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Act::Created => write!(f, "created"), + Act::Replaced(why) => write!(f, "replaced ({why})"), + Act::Kept(why) => write!(f, "kept ({why})"), + Act::Skipped(why) => write!(f, "skipped ({why})"), + Act::Removed(why) => write!(f, "removed ({why})"), + Act::Failed(why) => write!(f, "FAILED ({why})"), + } + } +} + +#[derive(Debug)] +pub struct Report { + pub slug: String, + pub licence: Licence, + pub archetype: String, + pub langs: Vec, + pub inherited_from: Option, + pub files: Vec<(String, Act)>, +} + +/// Mint (or realign) the provisioning set in `target`, returning a per-file report. +/// Realigns engine files, fills missing or replaceable templates, merges the +/// Justfile and inserts the README section. Folded secondary configs are removed. +/// Unless `opts.offline` is set, also locks mise tools and generates missing +/// Rust crate definitions through Guix. +/// +/// Licence refusals are returned before any writes. Invalid archetypes, canon +/// access/decoding errors, filesystem errors and engine invocation or output +/// errors are propagated; earlier writes are not rolled back. External steps +/// that run but fail their checks are recorded as `Act::Failed` in an otherwise +/// successful report. Callers must inspect the report for failures and skips. +pub fn mint(target: &Path, canon: &Canon, opts: &Options) -> Result { + let target = &target + .canonicalize() + .with_context(|| format!("resolving {}", target.display()))?; + let known = opts.repo.clone().or_else(|| origin_slug(target)); + let slug_is_guess = known.is_none(); + let slug = known.unwrap_or_else(|| { + format!( + "hyperpolymath/{}", + target.file_name().unwrap_or_default().to_string_lossy() + ) + }); + let name = slug.rsplit('/').next().unwrap_or(&slug).to_string(); + // Refuse before writing anything (standard §6). + let licence = licence::classify(target, &name)?; + let mut files = Vec::new(); + + for rel in ENGINE_FILES { + let act = write_file(target, rel, &canon.file(rel)?, "engine realigned")?; + files.push((rel.to_string(), act)); + } + + let lib = Lib(target.to_path_buf()); + let langs = lib.lines(&["langs"])?; + let old_deed = std::fs::read_to_string(target.join(DEED)).ok(); + let deed_repo = old_deed.as_deref().and_then(|d| deed_field(d, "repo")); + let inherited_from = deed_repo + .filter(|_| !slug_is_guess) + .filter(|r| r != &slug && !r.contains("__")); + let archetype = match &opts.archetype { + Some(a) => a.clone(), + None => old_deed + .as_deref() + .filter(|_| inherited_from.is_none()) + .and_then(|d| deed_field(d, "archetype")) + .filter(|a| ARCHETYPES.contains(&a.as_str())) + .unwrap_or_else(|| if langs == ["docs"] { "docs" } else { "library" }.to_string()), + }; + if !ARCHETYPES.contains(&archetype.as_str()) { + bail!( + "archetype {archetype:?} is not one of {}", + ARCHETYPES.join(", ") + ); + } + + let year = opts.year.unwrap_or_else(current_year); + let mut vars: BTreeMap<&str, String> = BTreeMap::new(); + vars.insert("APP_NAME", name.clone()); + vars.insert("REPO_SLUG", slug.clone()); + vars.insert("YEAR", year.to_string()); + vars.insert("COPYRIGHT_HOLDER", HOLDER.to_string()); + vars.insert("LICENSE", licence.code.to_string()); + vars.insert("DOC_LICENSE", licence.doc.to_string()); + vars.insert("GUIX_LICENSE", licence.guix.to_string()); + vars.insert("ARCHETYPE", archetype.clone()); + vars.insert("LANGS", langs.join(", ")); + + let m = Minter { + target, + canon, + lib: &lib, + inherited: inherited_from.is_some(), + }; + + // The deed first: the fact verbs below read it. + files.push(( + DEED.to_string(), + m.minted(DEED, &format!("{DEED}.tmpl"), &vars)?, + )); + + let guix_dir = lib.out(&["guix-dir"])?; + let gpre = if guix_dir == "build" { "build/" } else { "" }; + let (app_version, synopsis, description) = describe(target, &name); + let guix_specs = lib.words(&["guix-specs"])?; + let mise_tools = lib.words(&["mise-tools"])?; + vars.insert("GUIX_PREFIX", gpre.to_string()); + vars.insert("APP_VERSION", app_version); + vars.insert("SYNOPSIS", scheme_escape(&synopsis)); + vars.insert("DESCRIPTION", scheme_escape(&description)); + vars.insert("GUIX_GAPS", or_none(lib.out(&["guix-gaps"])?)); + vars.insert("TOOL_TABLE", lib.out(&["tool-table"])?); + vars.insert("SYSTEM_DEPS_SECTION", lib.out(&["system-deps", "adoc"])?); + vars.insert("SYSTEM_DEPS_AI", lib.out(&["system-deps", "ai"])?); + vars.insert( + "MISE_TOOLS", + mise_tools + .iter() + .map(|t| format!("`{t}`")) + .collect::>() + .join(", "), + ); + vars.insert("GUIX_SPECS", quoted_atoms(&guix_specs)); + vars.insert("GUIX_PKG_SPECS", quoted_atoms(&guix_specs)); + + // mise.toml: banned tools are replaced, and tool-only configs can be folded. + // Other settings need a manual merge: the template only carries tools. + let banned = lib.predicate(&["mise-banned"])?; + let secondary: Vec<&str> = SECONDARY_MISE + .into_iter() + .filter(|f| target.join(f).is_file()) + .collect(); + let fold_skip = if secondary.is_empty() && banned.is_none() { + None + } else { + mise_fold_skip_reason(target)? + }; + let (mut carried, mut notes) = (Vec::new(), Vec::new()); + if fold_skip.is_none() && (banned.is_some() || !secondary.is_empty()) { + (carried, notes) = carry_over_tools(target, banned.as_deref().unwrap_or(""))?; + } + let (toml_lines, floor_notes) = mise_tools_toml(&mise_tools, &carried); + notes.extend(floor_notes); + vars.insert("MISE_TOOLS_TOML", toml_lines); + let mut reasons = Vec::new(); + if let Some(hits) = &banned { + reasons.push(format!("pinned banned tool(s): {hits}")); + } + if !secondary.is_empty() { + reasons.push(format!("folded in {}", secondary.join(", "))); + } + reasons.extend(notes); + let replace_why = (fold_skip.is_none() + && (banned.is_some() && target.join("mise.toml").exists() || !secondary.is_empty())) + .then(|| reasons.join("; ")); + let act = match (&fold_skip, &replace_why) { + (Some(why), _) => Act::Skipped(why.clone()), + (_, Some(why)) => m.force("mise.toml", "mise.toml.tmpl", &vars, why)?, + _ => m.minted("mise.toml", "mise.toml.tmpl", &vars)?, + }; + files.push(("mise.toml".into(), act)); + for f in &secondary { + if let Some(why) = &fold_skip { + files.push(((*f).to_string(), Act::Skipped(why.clone()))); + continue; + } + std::fs::remove_file(target.join(f)).with_context(|| format!("removing {f}"))?; + files.push(( + (*f).to_string(), + Act::Removed("its tools were folded into mise.toml".into()), + )); + } + + // The Guix trio, beside whichever guix.scm the repository already keeps. + let cargo = langs.iter().any(|l| l == "rust") && target.join("Cargo.toml").is_file(); + let guix_tmpl = if cargo { + "guix/guix.scm.cargo.tmpl" + } else { + "guix/guix.scm.source.tmpl" + }; + let gs = format!("{gpre}guix.scm"); + files.push((gs.clone(), m.minted(&gs, guix_tmpl, &vars)?)); + let gm = format!("{gpre}manifest.scm"); + files.push((gm.clone(), m.minted(&gm, "guix/manifest.scm.tmpl", &vars)?)); + let gc = format!("{gpre}channels.scm"); + files.push((gc.clone(), m.minted_bytes(&gc, "guix/channels.scm")?)); + if cargo && !target.join("build/guix/crates.scm").is_file() { + let act = if opts.offline { + Act::Skipped( + "offline: generate with `just toolchain-refresh`; doctor reports PV-W24 until then" + .into(), + ) + } else { + crates_scm(&lib, &gs)? + }; + files.push(("build/guix/crates.scm".into(), act)); + } + + // Docs and warm-ups go where set-files puts them (root, docs/ or docs/onboarding/). + let set_files = lib.lines(&["set-files"])?; + let place = |base: &str| -> String { + set_files + .iter() + .find(|p| p.rsplit('/').next() == Some(base)) + .cloned() + .unwrap_or_else(|| base.to_string()) + }; + for (dest, tmpl) in [ + ("docs/SETUP.adoc".to_string(), "docs/SETUP.adoc.tmpl"), + ( + "docs/AI_INSTALLATION_GUIDE.adoc".to_string(), + "docs/AI_INSTALLATION_GUIDE.adoc.tmpl", + ), + (place("llm-warmup-user.adoc"), "llm-warmup-user.adoc.tmpl"), + (place("llm-warmup-dev.adoc"), "llm-warmup-dev.adoc.tmpl"), + ( + place("llm-warmup-maintainer.adoc"), + "llm-warmup-maintainer.adoc.tmpl", + ), + ] { + files.push((dest.clone(), m.minted(&dest, tmpl, &vars)?)); + } + + // launcher.sh: generated for library/tool/theory/docs; a hand-written + // launcher or an app's launcher (minted from its own config) is kept. + let launcher = target.join("launcher.sh"); + let act = match std::fs::read_to_string(&launcher) { + Ok(s) if !generated_launcher(&s) => { + Act::Kept("hand-written: give it the provisioning modes by sourcing build/just/provision-modes.sh".into()) + } + Err(_) if archetype == "app" => { + Act::Skipped("an app launcher is minted by `launch-scaffolder mint` from the app's config".into()) + } + _ => { + let body = render(&canon_text(canon, "launcher.sh.tmpl")?, &vars); + write_file(target, "launcher.sh", body.as_bytes(), "re-rendered from the deed")? + } + }; + files.push(("launcher.sh".into(), act)); + + let readme = render(&canon_text(canon, "README-ai-install.adoc.tmpl")?, &vars); + files.push(( + "README.adoc".into(), + super::readme::insert(target, &readme)?, + )); + + // Justfile: created whole when absent, otherwise merged (justfile.rs), which + // `just --summary` must accept or the original is restored. Several + // justfiles are folded into one first: `just` refuses to pick between them. + // Exact directory entries: on a case-insensitive filesystem `justfile` + // resolves to `Justfile`, and folding a file into itself deletes it. + let entries: Vec = std::fs::read_dir(target) + .with_context(|| format!("listing {}", target.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file())) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + let present: Vec<&str> = ["Justfile", "justfile", ".justfile"] + .into_iter() + .filter(|j| entries.iter().any(|e| e == j)) + .collect(); + let (justfile, folded) = match present.len() { + 0 => (None, Vec::new()), + 1 => (Some(present[0].to_string()), Vec::new()), + _ => { + let (kept, acts) = super::justfile::fold(target, &present)?; + (Some(kept), acts) + } + }; + let act = match justfile.as_deref() { + Some(j) => { + super::justfile::merge(target, j, &canon_text(canon, "build/just/provision.just")?)? + } + None => { + vars.insert( + "DELEGATIONS", + delegations(&canon_text(canon, "build/just/provision.just")?, &[]), + ); + let body = render(&canon_text(canon, "Justfile.tmpl")?, &vars); + write_file(target, "Justfile", body.as_bytes(), "")? + } + }; + files.push((justfile.unwrap_or_else(|| "Justfile".into()), act)); + files.extend(folded); + + // Last, because it reads the mise.toml written above. + let act = if opts.offline { + Act::Skipped("offline: run `mise lock`; provision-check fails until then".into()) + } else { + let act = mise_lock(&lib)?; + let dropped = match &act { + Act::Failed(why) => unpinnable(why, &carried), + _ => Vec::new(), + }; + match &replace_why { + Some(why) if !dropped.is_empty() => { + // A carried tool mise cannot pin (a name its registry does not + // know, such as the 07-18 sweep's `gnu-sed`) would fail + // provision-check for ever: drop it, once, and say so. + carried.retain(|(k, _)| !dropped.contains(k)); + vars.insert("MISE_TOOLS_TOML", mise_tools_toml(&mise_tools, &carried).0); + let why = format!( + "{why}; dropped {} carried tool(s) mise cannot pin: {}", + dropped.len(), + dropped.join(" ") + ); + let toml = m.force("mise.toml", "mise.toml.tmpl", &vars, &why)?; + if let Some(f) = files.iter_mut().find(|(p, _)| p == "mise.toml") { + f.1 = toml; + } + match mise_lock(&lib)? { + Act::Kept(_) => Act::Created, + relocked => relocked, + } + } + _ => act, + } + }; + files.push(("mise.lock".into(), act)); + + Ok(Report { + slug, + licence, + archetype, + langs, + inherited_from, + files, + }) +} + +struct Minter<'a> { + target: &'a Path, + canon: &'a Canon, + lib: &'a Lib, + inherited: bool, +} + +impl Minter<'_> { + /// A minted file: created when missing, replaced while a stub or inherited. + fn minted(&self, dest: &str, tmpl: &str, vars: &BTreeMap<&str, String>) -> Result { + let body = render(&canon_text(self.canon, tmpl)?, vars); + match self.stub_reason(dest)? { + Some(why) => write_file(self.target, dest, body.as_bytes(), &why), + None => Ok(Act::Kept("filled: owned by the repository".into())), + } + } + + /// Copy canon bytes into a missing, stub, or inherited file while preserving repository-owned content. + fn minted_bytes(&self, dest: &str, src: &str) -> Result { + match self.stub_reason(dest)? { + Some(why) => write_file(self.target, dest, &self.canon.file(src)?, &why), + None => Ok(Act::Kept("filled: owned by the repository".into())), + } + } + + /// Render and write a template regardless of stub ownership, recording the supplied reason. + fn force( + &self, + dest: &str, + tmpl: &str, + vars: &BTreeMap<&str, String>, + why: &str, + ) -> Result { + let body = render(&canon_text(self.canon, tmpl)?, vars); + write_file(self.target, dest, body.as_bytes(), why) + } + + /// Why `dest`, relative to the target, may be (re)written, or `None` when + /// the repository owns it. Any read or UTF-8 error is treated as missing; + /// errors from the Guix stub predicate are propagated. + fn stub_reason(&self, dest: &str) -> Result> { + let path = self.target.join(dest); + let Ok(text) = std::fs::read_to_string(&path) else { + return Ok(Some("missing".into())); + }; + if self.inherited { + return Ok(Some("inherited from another repository's set".into())); + } + if dest.ends_with(".scm") { + return Ok(self + .lib + .predicate(&["guix-stub", dest])? + .map(|r| format!("Guix stub: {r}"))); + } + if let Some(slot) = mechanical_residue(&text) { + return Ok(Some(format!("never minted: __{slot}__ unfilled"))); + } + if dest.contains("llm-warmup-") + && text.contains("for overview.") + && text.contains("Key Commands") + { + return Ok(Some("generic warm-up boilerplate".into())); + } + Ok(None) + } +} + +/// The mechanical slots the generator fills. Residue of one of these means the +/// file was never minted; `__SPEC_*__` residue means it was minted but not yet +/// specialised, which is the repository's to finish (doctor PV-W29). +const MECHANICAL: &[&str] = &[ + "APP_NAME", + "REPO_SLUG", + "YEAR", + "COPYRIGHT_HOLDER", + "LICENSE", + "DOC_LICENSE", + "GUIX_LICENSE", + "ARCHETYPE", + "LANGS", + "GUIX_PREFIX", + "APP_VERSION", + "SYNOPSIS", + "DESCRIPTION", + "GUIX_GAPS", + "TOOL_TABLE", + "SYSTEM_DEPS_SECTION", + "SYSTEM_DEPS_AI", + "MISE_TOOLS", + "MISE_TOOLS_TOML", + "GUIX_SPECS", + "GUIX_PKG_SPECS", + "DELEGATIONS", +]; + +/// Return the first known mechanical slot still present in text, ignoring repository-specific slots. +fn mechanical_residue(text: &str) -> Option<&'static str> { + MECHANICAL + .iter() + .copied() + .find(|k| text.contains(&format!("__{k}__"))) +} + +/// Replace every `__KEY__` whose KEY is in `vars`, in one left-to-right pass, so +/// a value can never be re-substituted (a description that mentions `__init__` +/// stays as written). Keys must start with an ASCII uppercase letter and contain +/// only ASCII uppercase letters, digits or underscores; other slots stay unchanged. +/// A value marked as a list of atoms is wrapped towards [`WRAP`] UTF-8 bytes per +/// line, reserving three bytes for closing parentheses and aligning continuation +/// lines under the first atom. Individual atoms are never split. +pub fn render(tmpl: &str, vars: &BTreeMap<&str, String>) -> String { + let mut out = String::with_capacity(tmpl.len()); + let mut rest = tmpl; + while let Some(i) = rest.find("__") { + out.push_str(&rest[..i]); + let after = &rest[i + 2..]; + let key = after.find("__").map(|j| &after[..j]).filter(|k| { + k.starts_with(|c: char| c.is_ascii_uppercase()) + && k.chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + }); + match key.and_then(|k| vars.get(k).map(|v| (k, v))) { + Some((k, v)) => { + if let Some(atoms) = v.strip_prefix(ATOMS) { + let col = out.len() - out.rfind('\n').map_or(0, |n| n + 1); + out.push_str(&wrap_atoms(atoms, col)); + } else { + out.push_str(v); + } + rest = &after[k.len() + 2..]; + } + None => { + out.push('_'); + rest = &rest[i + 1..]; + } + } + } + out.push_str(rest); + out +} + +/// Marks a value as space-separated Scheme atoms for [`render`] to wrap. +const ATOMS: &str = "\u{0}atoms\u{0}"; + +/// Escape and quote Scheme strings, marking the result for atom wrapping during rendering. +fn quoted_atoms(specs: &[String]) -> String { + let atoms: Vec = specs + .iter() + .map(|s| format!("\"{}\"", scheme_escape(s))) + .collect(); + format!("{ATOMS}{}", atoms.join(" ")) +} + +/// Wrap space-separated atoms at the template column, reserving room for closing parentheses. +fn wrap_atoms(atoms: &str, col: usize) -> String { + let mut out = String::new(); + let mut width = col; + for (n, a) in atoms.split(' ').filter(|a| !a.is_empty()).enumerate() { + if n > 0 { + // Room for " atom" plus the closing parens the template adds. + if width + 1 + a.len() > WRAP - 3 { + out.push('\n'); + out.push_str(&" ".repeat(col)); + width = col; + } else { + out.push(' '); + width += 1; + } + } + out.push_str(a); + width += a.len(); + } + out +} + +/// Escape backslashes and double quotes for a Scheme string literal. +fn scheme_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// Replace whitespace-only text with "none" and otherwise preserve the original string. +fn or_none(s: String) -> String { + if s.trim().is_empty() { + "none".into() + } else { + s + } +} + +/// `[tools]` lines: the canon's tools, then the other entries carried over from +/// a replaced `mise.toml`, plus one note per carried pin that was raised. A +/// carried entry keeps its own value (the repository's pin of a tool the canon +/// also lists is a decision, not drift) unless it is below a floor in +/// [`TOOL_FLOORS`], which is raised to `latest`. +fn mise_tools_toml(tools: &[String], carried: &[(String, String)]) -> (String, Vec) { + let mut notes = Vec::new(); + let mut value = |t: &String| match carried.iter().find(|(k, _)| k == t) { + None => "\"latest\"".to_string(), + Some((_, v)) => match below_floor(t, v) { + Some(floor) => { + notes.push(format!("raised {t} {v} to latest (floor {floor})")); + "\"latest\"".to_string() + } + None => v.clone(), + }, + }; + let mut lines: Vec = tools + .iter() + .map(|t| format!("{} = {}", toml_key(t), value(t))) + .collect(); + for (k, v) in carried { + if !tools.iter().any(|t| t == k) { + lines.push(format!("{} = {v}", toml_key(k))); + } + } + (lines.join("\n"), notes) +} + +/// The floor `tool`'s pin `value` (a TOML value) is below, if any. A prefix pin +/// such as `"1"` is below only when no version it selects can meet the floor. +fn below_floor(tool: &str, value: &str) -> Option<&'static str> { + let (_, floor) = TOOL_FLOORS.iter().find(|(t, _)| *t == tool)?; + let pin: Vec = value + .trim_matches('"') + .split('.') + .map(str::parse) + .collect::>() + .ok()?; + let want: Vec = floor.split('.').map(|p| p.parse().unwrap_or(0)).collect(); + for (p, w) in pin.iter().zip(&want) { + if p != w { + return (p < w).then_some(*floor); + } + } + None +} + +/// Emit a bare TOML key when possible, otherwise quote and escape it. +fn toml_key(k: &str) -> String { + if k.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + k.to_string() + } else { + format!("\"{}\"", k.replace('\\', "\\\\").replace('"', "\\\"")) + } +} +/// A `(tool, TOML value)` pin carried from an existing mise config. +type Pin = (String, String); + +/// Why folding would discard settings outside `[tools]`, or `None` if neither +/// mise TOML file has such settings. Missing files and invalid TOML do not +/// block folding here; errors reading an existing file are propagated. +fn mise_fold_skip_reason(target: &Path) -> Result> { + for f in ["mise.toml", ".mise.toml"] { + let path = target.join(f); + if !path.is_file() { + continue; + } + let text = std::fs::read_to_string(&path) + .with_context(|| format!("reading {}", path.display()))?; + if toml::from_str::(&text) + .is_ok_and(|table| table.keys().any(|key| key != "tools")) + { + return Ok(Some(format!( + "{f} has settings outside [tools]: fold the mise configs by hand" + ))); + } + } + Ok(None) +} + +/// The non-banned tools of every repository mise config, values as TOML, and a +/// note per file that is not TOML at all: mise cannot read such a file either, +/// so nothing in it was in effect to carry. Files are read lowest precedence +/// first, so a later file's pin of the same tool replaces an earlier one, which +/// is the pin `mise ls --current` reports as in effect. +/// `banned` contains whitespace-separated tool names. Unreadable files are +/// ignored; `.tool-versions` contributes only the first version of each tool. +fn carry_over_tools(target: &Path, banned: &str) -> Result<(Vec, Vec)> { + let banned: Vec<&str> = banned.split_whitespace().collect(); + let (mut out, mut notes): (Vec<(String, String)>, Vec) = (Vec::new(), Vec::new()); + let mut carry = |k: &str, v: String| { + if banned.contains(&k) { + return; + } + match out.iter_mut().find(|(o, _)| o == k) { + Some(slot) => slot.1 = v, + None => out.push((k.to_string(), v)), + } + }; + for f in MISE_PRECEDENCE { + let Ok(text) = std::fs::read_to_string(target.join(f)) else { + continue; + }; + if f == ".tool-versions" { + // `tool version [fallback…]`: the first version is the one in effect. + for line in text.lines().map(str::trim) { + let mut w = line.split_whitespace(); + if let (Some(k), Some(v)) = (w.next(), w.next()) { + if !k.starts_with('#') { + carry(k, toml::Value::String(v.to_string()).to_string()); + } + } + } + continue; + } + let table: toml::Table = match toml::from_str(&text) { + Ok(t) => t, + Err(e) => { + let why = e.message().trim().to_string(); + notes.push(format!( + "{f} was not valid TOML ({why}), so no tool was carried from it" + )); + continue; + } + }; + if let Some(tools) = table.get("tools").and_then(|t| t.as_table()) { + for (k, v) in tools { + carry(k, v.to_string()); + } + } + } + Ok((out, notes)) +} + +/// One root delegation per contract verb the root Justfile does not define, +/// each with the module recipe's own doc comment, e.g. +/// `build: provision::build` or `ai-warmup who="user": (provision::ai-warmup who)`. +pub fn delegations(provision_just: &str, existing: &[String]) -> String { + let mut out = Vec::new(); + let mut doc: Option<&str> = None; + for line in provision_just.lines() { + if let Some(d) = line.strip_prefix("# ") { + doc = Some(d); + continue; + } + let sig = line.trim_end(); + let is_recipe = sig.starts_with(|c: char| c.is_ascii_lowercase()) + && sig.ends_with(':') + && !sig.contains(":="); + if !is_recipe { + if !line.starts_with(' ') { + doc = None; + } + continue; + } + let sig = &sig[..sig.len() - 1]; + let (name, params) = sig.split_once(' ').unwrap_or((sig, "")); + if VERBS.contains(&name) && !existing.iter().any(|e| e == name) { + let comment = doc.map(|d| format!("# {d}\n")).unwrap_or_default(); + if params.is_empty() { + out.push(format!("{comment}{name}: provision::{name}")); + } else { + let args: Vec<&str> = params + .split_whitespace() + .map(|p| p.split('=').next().unwrap_or(p)) + .collect(); + out.push(format!( + "{comment}{name} {params}: (provision::{name} {})", + args.join(" ") + )); + } + } + doc = None; + } + out.join("\n\n") +} + +/// `true` for a launcher this generator rendered (and may re-render). +fn generated_launcher(text: &str) -> bool { + text.contains("@launcher-deed begin") && text.contains(":generator \"provision-set\"") +} + +/// `:key "value"` from a deed. +fn deed_field(deed: &str, key: &str) -> Option { + let pat = format!(":{key} "); + deed.lines().find_map(|l| { + let l = l.trim_start(); + let rest = l.strip_prefix(&pat)?.trim_start().strip_prefix('"')?; + rest.split_once('"').map(|(v, _)| v.to_string()) + }) +} + +/// `owner/name` from the `origin` remote of a GitHub checkout. +/// Returns `None` if Git fails, its output is not UTF-8, or no slug can be extracted. +fn origin_slug(target: &Path) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(target) + .args(["remote", "get-url", "origin"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let url = String::from_utf8(out.stdout).ok()?; + let url = url.trim().trim_end_matches('/').trim_end_matches(".git"); + let path = url + .split_once("github.com") + .map(|(_, p)| p.trim_start_matches([':', '/']))?; + let mut parts = path.splitn(2, '/'); + let (o, r) = (parts.next()?, parts.next()?); + (!o.is_empty() && !r.is_empty() && !r.contains('/')).then(|| format!("{o}/{r}")) +} + +/// Return `(version, synopsis, description)` from Cargo package metadata. +/// The version defaults to `0.1.0`; the description falls back to the first +/// README prose paragraph, then a sentence naming the repository. Whitespace +/// is collapsed and the synopsis is derived from the description. Unreadable +/// or invalid metadata is ignored when choosing these fallbacks. +fn describe(target: &Path, name: &str) -> (String, String, String) { + let cargo: Option = std::fs::read_to_string(target.join("Cargo.toml")) + .ok() + .and_then(|t| toml::from_str(&t).ok()); + let field = |k: &str| -> Option { + let c = cargo.as_ref()?; + let pkg = c.get("package")?.as_table()?; + match pkg.get(k)? { + toml::Value::String(s) => Some(s.clone()), + // `version.workspace = true`: the workspace's own value. + _ => c + .get("workspace")? + .get("package")? + .get(k)? + .as_str() + .map(str::to_string), + } + }; + let version = field("version").unwrap_or_else(|| "0.1.0".into()); + let description = field("description") + .or_else(|| readme_paragraph(target)) + .unwrap_or_else(|| format!("{name}, a hyperpolymath repository.")); + let description = description.split_whitespace().collect::>().join(" "); + (version, synopsis_of(&description), description) +} + +/// Extract the first prose paragraph from the first readable README, skipping markup and blocks. +fn readme_paragraph(target: &Path) -> Option { + let text = ["README.adoc", "README.md", "README"] + .iter() + .find_map(|f| std::fs::read_to_string(target.join(f)).ok())?; + let mut para = Vec::new(); + let mut in_block = false; + for line in text.lines() { + let t = line.trim(); + if t == "----" || t == "...." || t == "```" || t.starts_with("```") || t == "////" { + in_block = !in_block; + continue; + } + let markup = t.starts_with(['=', '#', ':', '[', '!', '<', '|', '*', '-', '.', '+', '>']) + || t.starts_with("//") + || t.starts_with("image:") + || t.starts_with("ifdef") + || t.starts_with("endif") + || t.starts_with("toc::"); + if in_block || t.is_empty() || markup { + if !para.is_empty() && (t.is_empty() || markup) { + break; + } + continue; + } + para.push(t); + } + (!para.is_empty()).then(|| para.join(" ")) +} + +/// Text before the first `. `, without trailing full stops, limited to 79 UTF-8 +/// bytes. Longer text is shortened at word boundaries and may become empty. +fn synopsis_of(description: &str) -> String { + let first = description + .split(". ") + .next() + .unwrap_or(description) + .trim_end_matches('.'); + if first.len() <= 79 { + return first.to_string(); + } + let mut s = String::new(); + for w in first.split_whitespace() { + if s.len() + w.len() + 1 > 79 { + break; + } + if !s.is_empty() { + s.push(' '); + } + s.push_str(w); + } + s +} + +/// Derive the year from SOURCE_DATE_EPOCH, falling back to the system clock. +fn current_year() -> i64 { + let secs = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) + }); + year_of_days(secs.div_euclid(86_400)) +} + +/// The proleptic Gregorian year of a day count since 1970-01-01 (Hinnant's +/// `civil_from_days`). +fn year_of_days(z: i64) -> i64 { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + yoe + era * 400 + i64::from(m <= 2) +} + +/// Read a canon file as UTF-8, reporting its path if decoding fails. +fn canon_text(canon: &Canon, rel: &str) -> Result { + String::from_utf8(canon.file(rel)?.into_owned()).with_context(|| format!("{rel} is not UTF-8")) +} + +/// Write `bytes` to `target/rel`, creating parent directories as needed. Returns +/// `Kept` for identical content, `Replaced(why)` for changed readable content, +/// or `Created` when the previous content could not be read. +/// On Unix, `.sh` files get mode 0755 even when their content is unchanged. +/// Directory creation, write and permission errors propagate without rollback. +fn write_file(target: &Path, rel: &str, bytes: &[u8], why: &str) -> Result { + let path = target.join(rel); + let old = std::fs::read(&path).ok(); + if old.as_deref() == Some(bytes) { + set_exec(&path, rel)?; + return Ok(Act::Kept("up to date".into())); + } + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + std::fs::write(&path, bytes).with_context(|| format!("writing {}", path.display()))?; + set_exec(&path, rel)?; + Ok(if old.is_some() { + Act::Replaced(why.to_string()) + } else { + Act::Created + }) +} + +/// On Unix, set shell-script permissions to 0755; leave other paths and platforms unchanged. +fn set_exec(path: &Path, rel: &str) -> Result<()> { + #[cfg(unix)] + if rel.ends_with(".sh") { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod {}", path.display()))?; + } + let _ = (path, rel); + Ok(()) +} + +/// The engine's `provision-lib.sh`, run against the target. A verb that fails +/// is an error: a fact the generator cannot establish is never guessed. +/// The Guix command for `crates-scm`, e.g. a wrapper that runs guix in a +/// container with the target mounted; the engine reads it as `GUIX`. +pub const GUIX_ENV: &str = "LAUNCH_SCAFFOLDER_GUIX"; + +/// Write `build/guix/crates.scm` through the engine's `crates-scm` verb. The +/// result is accepted only when `guix-stub` then passes on `guix_scm`: a +/// containerised guix loses its exit status, so the file is the evidence. +/// Returns `Created` only if the command succeeds and the stub check passes; +/// otherwise returns `Failed`. Process I/O errors and unexpected predicate +/// exits are propagated as errors. +fn crates_scm(lib: &Lib, guix_scm: &str) -> Result { + let guix = std::env::var(GUIX_ENV).unwrap_or_else(|_| "guix".into()); + let o = lib.run_env(&["crates-scm"], &[("GUIX", &guix)])?; + match lib.predicate(&["guix-stub", guix_scm])? { + None if o.status.success() => Ok(Act::Created), + why => Ok(Act::Failed(format!( + "{}crates-scm: {}", + why.map(|w| format!("{guix_scm}: {w}; ")) + .unwrap_or_default(), + last_line(&[&o.stdout, &o.stderr], &o.status) + ))), + } +} + +/// Pin `mise.toml` in `mise.lock` with `mise lock`, unless the engine's +/// `mise-lock-gaps` already finds every tool pinned and checksummed: bumping +/// versions is `toolchain-refresh`'s job, not mint's. The target is trusted +/// for this one process through the environment, not mise's trust database. +/// Runs with a 600-second timeout. The post-run gap check determines success +/// regardless of the command's exit status; remaining gaps yield `Act::Failed`. +/// Failure to run or capture the lock command also yields `Act::Failed`; +/// process I/O errors and unexpected exits from the gap predicate are propagated. +fn mise_lock(lib: &Lib) -> Result { + if lib.predicate(&["mise-lock-gaps"])?.is_none() { + return Ok(Act::Kept("pinned and checksummed".into())); + } + let existed = lib.0.join("mise.lock").is_file(); + let o = match Command::new("timeout") + .args(["600", "mise", "lock"]) + .env("MISE_TRUSTED_CONFIG_PATHS", &lib.0) + .current_dir(&lib.0) + .output() + { + Ok(o) => o, + Err(e) => { + return Ok(Act::Failed(format!( + "cannot run `timeout 600 mise lock`: {e}" + ))); + } + }; + Ok(match lib.predicate(&["mise-lock-gaps"])? { + None if existed => Act::Replaced("re-locked: the old lock had gaps".into()), + None => Act::Created, + Some(gap) => Act::Failed(format!( + "{gap}; mise lock: {}", + last_line(&[&o.stderr, &o.stdout], &o.status) + )), + }) +} + +/// The carried-over tools named in a `mise-lock-gaps` "does not pin" verdict: +/// the ones mint itself brought in and may therefore take out again. A canon +/// tool absent from `carried` is not returned. A canon tool also present in +/// `carried` is eligible for removal. +fn unpinnable(gap: &str, carried: &[(String, String)]) -> Vec { + let Some(rest) = gap.strip_prefix("mise.lock does not pin: ") else { + return Vec::new(); + }; + let named = rest.split(';').next().unwrap_or(""); + named + .split_whitespace() + .filter(|t| carried.iter().any(|(k, _)| k == t)) + .map(str::to_string) + .collect() +} + +/// From the first stream with a nonblank line, the first error line (mise ends +/// with a version and a "Run with --verbose" trailer, which name nothing), else +/// its last nonblank line. Falls back to the exit status if all streams are blank. +fn last_line(streams: &[&[u8]], status: &std::process::ExitStatus) -> String { + streams + .iter() + .find_map(|s| { + let text = String::from_utf8_lossy(s); + let lines: Vec<&str> = text + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect(); + lines + .iter() + .find(|l| l.contains("ERROR") || l.starts_with("error:")) + .or(lines.last()) + .map(|l| l.to_string()) + }) + .unwrap_or_else(|| format!("no output, {status}")) +} + +/// The target repository's engine library, `build/just/provision-lib.sh`. +struct Lib(PathBuf); + +impl Lib { + /// Run a verb of the target's `provision-lib.sh`. + fn run(&self, args: &[&str]) -> Result { + self.run_env(args, &[]) + } + + /// Run a verb of the target's `provision-lib.sh` with extra environment, + /// using the target as the working directory and `PROVISION_ROOT`. + /// Returns captured output even on nonzero exit; process I/O errors are + /// propagated. + fn run_env(&self, args: &[&str], env: &[(&str, &str)]) -> Result { + Command::new("bash") + .arg(self.0.join(LIB)) + .args(args) + .env("PROVISION_ROOT", &self.0) + .envs(env.iter().copied()) + .current_dir(&self.0) + .output() + .with_context(|| format!("running {LIB} {}", args.join(" "))) + } + + /// Run an engine verb and return UTF-8 stdout without trailing newlines, failing on nonzero status. + /// Process I/O and UTF-8 decoding errors are propagated. + fn out(&self, args: &[&str]) -> Result { + let o = self.run(args)?; + if !o.status.success() { + bail!( + "{LIB} {} failed ({}): {}", + args.join(" "), + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ); + } + Ok(String::from_utf8(o.stdout)? + .trim_end_matches('\n') + .to_string()) + } + + /// Return the nonempty output lines of a successful engine verb. + fn lines(&self, args: &[&str]) -> Result> { + Ok(self + .out(args)? + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect()) + } + + /// Split the output of a successful engine verb into whitespace-delimited words. + fn words(&self, args: &[&str]) -> Result> { + Ok(self + .out(args)? + .split_whitespace() + .map(str::to_string) + .collect()) + } + + /// A predicate verb: exit 0 returns `None`, exit 1 returns trimmed stdout + /// as `Some`, replacing invalid UTF-8. Other exits, signal termination and + /// process I/O failures return errors. + fn predicate(&self, args: &[&str]) -> Result> { + let o = self.run(args)?; + match o.status.code() { + Some(0) => Ok(None), + Some(1) => Ok(Some(String::from_utf8_lossy(&o.stdout).trim().to_string())), + _ => bail!( + "{LIB} {} failed ({}): {}", + args.join(" "), + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build owned template substitution values from borrowed test data. + fn vars(kv: &[(&'static str, &str)]) -> BTreeMap<&'static str, String> { + kv.iter().map(|(k, v)| (*k, v.to_string())).collect() + } + + /// Verify substitutions are not expanded recursively and unknown slots remain intact. + #[test] + fn render_is_single_pass_and_leaves_unknown_slots() { + let v = vars(&[("A", "__B__ and __init__"), ("B", "x")]); + assert_eq!( + render("__A__ / __B__ / __SPEC_Q__ / a__b", &v), + "__B__ and __init__ / x / __SPEC_Q__ / a__b" + ); + assert_eq!(render("___A__", &vars(&[("A", "v")])), "_v"); + } + + /// Verify rendered Scheme atoms wrap within the width limit and align with the first atom. + #[test] + fn atoms_wrap_under_the_first_atom() { + let specs: Vec = [ + "git", + "bash", + "coreutils", + "nss-certs", + "just", + "mise", + "shellcheck", + "chez-scheme", + "gmp", + "gcc-toolchain", + "zig", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + let v: BTreeMap<&str, String> = [("S", quoted_atoms(&specs))].into_iter().collect(); + let out = render(" (list __S__))", &v); + for line in out.lines() { + assert!(line.len() <= WRAP, "{line:?} is wider than {WRAP}"); + } + let cont: Vec<&str> = out.lines().skip(1).collect(); + assert!(!cont.is_empty(), "eleven specs must wrap"); + assert!(cont.iter().all(|l| l.starts_with(" \"")), "{out}"); + let tokens: Vec<&str> = out.split_whitespace().collect(); + assert_eq!(tokens[1], "\"git\""); + assert_eq!(tokens.last(), Some(&"\"zig\"))")); + } + + /// Verify canon delegations retain contract verbs, parameters, and docs while omitting overrides. + #[test] + fn delegations_cover_every_verb_with_its_doc() { + let canon = Canon::Baked; + let pj = canon_text(&canon, "build/just/provision.just").unwrap(); + let d = delegations(&pj, &[]); + for v in VERBS { + assert!( + d.lines() + .any(|l| l.starts_with(&format!("{v}:")) || l.starts_with(&format!("{v} "))), + "no delegation for {v}:\n{d}" + ); + } + assert!(d.contains("# Diagnose the environment")); + assert!(d.contains("ai-warmup who=\"user\": (provision::ai-warmup who)")); + assert!(d.contains("fmt-check: provision::fmt-check")); + assert!(!d.contains("langs:"), "langs is not a contract verb"); + let some = delegations(&pj, &["doctor".into(), "build".into()]); + assert!(!some.contains("doctor:") && !some.contains("build:") && some.contains("heal:")); + } + + /// Verify quoted deed fields are extracted and absent fields return no value. + #[test] + fn deed_fields_and_slugs() { + let d = "(praxis-deed\n :repo \"hyperpolymath/rsr-template-repo\"\n :archetype \"library\" ; app | …\n"; + assert_eq!( + deed_field(d, "repo").as_deref(), + Some("hyperpolymath/rsr-template-repo") + ); + assert_eq!(deed_field(d, "archetype").as_deref(), Some("library")); + assert_eq!(deed_field(d, "ports"), None); + } + + /// Verify synopsis truncation and Scheme string escaping for generated package metadata. + #[test] + fn synopsis_is_one_short_sentence() { + assert_eq!(synopsis_of("Does a thing. Then more."), "Does a thing"); + let long = "word ".repeat(40); + assert!(synopsis_of(&long).len() <= 79); + assert_eq!(scheme_escape(r#"a "q" \ b"#), r#"a \"q\" \\ b"#); + } + + /// Verify Gregorian year conversion at the epoch, a year boundary, and a leap day. + #[test] + fn years_from_day_counts() { + assert_eq!(year_of_days(0), 1970); + assert_eq!(year_of_days(20_454), 2026); // 2026-01-01 + assert_eq!(year_of_days(20_453), 2025); // 2025-12-31 + assert_eq!(year_of_days(11_016), 2000); // 2000-02-29 + } + + /// Verify lock failures identify only explicitly unpinned tools carried from existing config. + #[test] + fn unpinnable_names_only_carried_tools() { + let carried = vec![ + ("gnu-sed".to_string(), "\"latest\"".to_string()), + ("zig".to_string(), "\"0.14\"".to_string()), + ]; + let gap = "mise.lock does not pin: bun gnu-sed; mise lock: failed"; + assert_eq!(unpinnable(gap, &carried), vec!["gnu-sed".to_string()]); + assert!(unpinnable("mise.lock is empty", &carried).is_empty()); + assert!(unpinnable("mise.lock has no sha256 for: zig/linux-x64", &carried).is_empty()); + } + + /// Verify existing pins override canon defaults and additional tools follow the canon entries. + #[test] + fn carried_pins_survive_and_extras_follow() { + let tools = vec!["just".to_string(), "rust".to_string()]; + let carried = vec![ + ("rust".to_string(), "\"1.85\"".to_string()), + ("cargo:cargo-nextest".to_string(), "\"latest\"".to_string()), + ]; + assert_eq!( + mise_tools_toml(&tools, &carried).0, + "just = \"latest\"\nrust = \"1.85\"\n\"cargo:cargo-nextest\" = \"latest\"" + ); + } + + /// Verify banned tools are removed and invalid TOML produces a note instead of carried pins. + #[test] + fn carry_over_drops_banned_and_survives_a_file_that_is_not_toml() { + let d = std::env::temp_dir().join(format!("carry-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let mise = d.join("mise.toml"); + std::fs::write(&mise, "[tools]\npython = \"3\"\nzig = \"0.14\"\n").unwrap(); + let (carried, note) = carry_over_tools(&d, "python").unwrap(); + assert_eq!(carried, vec![("zig".to_string(), "\"0.14\"".to_string())]); + assert!(note.is_empty()); + std::fs::write(&mise, "[tools]\nbun = \"1\"\nbun = \"1\"\n").unwrap(); + let (carried, note) = carry_over_tools(&d, "python").unwrap(); + assert!(carried.is_empty()); + assert!( + note.iter() + .any(|n| n.contains("not valid TOML") && n.contains("duplicate key")) + ); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Verify all supported mise configs contribute tools in the required precedence order. + #[test] + fn carry_over_reads_every_config_and_the_winning_pin_wins() { + let d = std::env::temp_dir().join(format!("carry-prec-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write( + d.join(".tool-versions"), + "# pins\njust 1.30.0 1.29.0\nzig 0.13\npython 3.12\n", + ) + .unwrap(); + std::fs::write(d.join("mise.toml"), "[tools]\njust = \"1.40.0\"\n").unwrap(); + std::fs::write( + d.join(".mise.toml"), + "[tools]\nrust = \"1.95.0\"\njust = \"1.43.0\"\n", + ) + .unwrap(); + let (carried, notes) = carry_over_tools(&d, "python").unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + // .mise.toml beats mise.toml beats .tool-versions, as `mise ls --current` reports. + assert_eq!( + carried, + vec![ + ("just".to_string(), "\"1.43.0\"".to_string()), + ("zig".to_string(), "\"0.13\"".to_string()), + ("rust".to_string(), "\"1.95.0\"".to_string()), + ] + ); + std::fs::remove_dir_all(&d).unwrap(); + } + + /// Verify pins below the Just floor are raised with a note while compatible selectors are preserved. + #[test] + fn a_carried_pin_below_a_floor_is_raised_and_said() { + let tools = vec!["just".to_string()]; + for (pin, raised) in [ + ("\"1.36.0\"", true), + ("\"1.41\"", true), + ("\"0.9\"", true), + ("\"1.42.0\"", false), + ("\"1.58.0\"", false), + ("\"2\"", false), + ("\"1\"", false), // a prefix that can select 1.42+ + ("\"latest\"", false), + ] { + let carried = vec![("just".to_string(), pin.to_string())]; + let (toml, notes) = mise_tools_toml(&tools, &carried); + let want = if raised { + "just = \"latest\"".to_string() + } else { + format!("just = {pin}") + }; + assert_eq!(toml, want, "{pin}"); + assert_eq!(notes.len(), usize::from(raised), "{pin}: {notes:?}"); + } + } + + /// Verify the deed and shell engine agree on banned tool names and backends. + #[test] + fn the_deeds_banned_lists_are_the_engines() { + let deed = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/provisioning-standard_praxis.deed" + )); + let lib = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/templates/build/just/provision-lib.sh" + )); + // The quoted words of a deed list, which may wrap over several lines. + let deed_list = |key: &str| -> Vec { + let at = deed + .find(key) + .unwrap_or_else(|| panic!("deed has no {key}")); + let body = &deed[at..][..deed[at..].find(')').unwrap()]; + let mut v: Vec = body + .split('"') + .skip(1) + .step_by(2) + .map(String::from) + .collect(); + v.sort(); + v + }; + let lib_list = |var: &str| -> Vec { + let line = lib + .lines() + .find(|l| l.starts_with(&format!("{var}='"))) + .unwrap(); + let mut v: Vec = line[var.len() + 2..line.len() - 1] + .split('|') + .map(String::from) + .collect(); + v.sort(); + v + }; + assert_eq!(deed_list(":banned-tools "), lib_list("BANNED_TOOLS")); + assert_eq!(deed_list(":banned-backends "), lib_list("BANNED_BACKENDS")); + } + + /// Verify the Rust Just version floor matches the value declared by the canon deed. + #[test] + fn the_just_floor_matches_the_canon_deed() { + let deed = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/provisioning-standard_praxis.deed" + )); + let floor = TOOL_FLOORS.iter().find(|(t, _)| *t == "just").unwrap().1; + assert!( + deed.contains(&format!(":just-floor \"{floor}\"")), + "deed and TOOL_FLOORS disagree" + ); + } + + /// Verify repository-specific slots are allowed while unfilled mechanical slots are detected. + #[test] + fn mechanical_residue_ignores_spec_slots() { + assert_eq!(mechanical_residue("x __SPEC_USAGE__ y"), None); + assert_eq!(mechanical_residue("x __APP_NAME__ y"), Some("APP_NAME")); + } +} diff --git a/crates/launcher-common/src/provisioning/mod.rs b/crates/launcher-common/src/provisioning/mod.rs new file mode 100644 index 0000000..1addeb7 --- /dev/null +++ b/crates/launcher-common/src/provisioning/mod.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! The provisioning set (`standards/3-practice/provisioning/PROVISIONING-STANDARD.adoc`): +//! the canon it is minted from and the conformance check run against it. + +pub mod canon; +pub mod check; +pub mod justfile; +pub mod licence; +pub mod mint; +pub mod readme; diff --git a/crates/launcher-common/src/provisioning/readme.rs b/crates/launcher-common/src/provisioning/readme.rs new file mode 100644 index 0000000..6b7d19e --- /dev/null +++ b/crates/launcher-common/src/provisioning/readme.rs @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! Insert the README `[[ai-install]]` section (`PROVISIONING-STANDARD.adoc` §1: +//! *inserted*, never regenerated). +//! +//! The rendered `README-ai-install.adoc.tmpl` has two parts: a TIP that goes +//! straight after the document header, and the `[[ai-install]]` section that +//! goes before the first level-2 section. Once present the section belongs to +//! the README and is never touched again; a README that already has an +//! "AI-Assisted Installation" section of its own only gains the anchor, so the +//! launcher's `ai-setup` can read its sentence. + +use super::mint::Act; +use anyhow::{Context, Result}; +use std::path::Path; + +const ANCHOR: &str = "[[ai-install]]"; + +/// Insert `rendered` (the template with the repo's values) into `README.adoc`. +/// Returns `Skipped` if that file is absent and `Kept` if its anchor is already +/// present. An existing AI-assisted installation section gains only the anchor; +/// otherwise the rendered content is inserted. Successful writes return +/// `Replaced`; read, UTF-8 decoding and write errors are propagated. +pub fn insert(target: &Path, rendered: &str) -> Result { + let path = target.join("README.adoc"); + if !path.is_file() { + let why = if target.join("README.md").is_file() { + "README.md: the section is AsciiDoc; add it by hand" + } else { + "no README.adoc to insert the AI-install section into" + }; + return Ok(Act::Skipped(why.into())); + } + let text = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let Some(out) = merged(&text, rendered) else { + return Ok(Act::Kept("README.adoc already has [[ai-install]]".into())); + }; + std::fs::write(&path, &out.0).with_context(|| format!("writing {}", path.display()))?; + Ok(Act::Replaced(out.1.into())) +} + +/// The README with the section in place and what was done, or `None` when it +/// already has the anchor. +fn merged(text: &str, rendered: &str) -> Option<(String, &'static str)> { + let lines: Vec<&str> = text.lines().collect(); + if lines.iter().any(|l| l.trim() == ANCHOR) { + return None; + } + let sections = level2_headings(&lines); + + // An existing section of the same purpose gains the anchor and nothing else. + if let Some(&i) = sections.iter().find(|&&i| { + lines[i] + .to_ascii_lowercase() + .starts_with("== ai-assisted install") + }) { + let mut out: Vec<&str> = lines.clone(); + out.insert(i, ANCHOR); + return Some(( + join(&out), + "anchor added to the existing AI-assisted section", + )); + } + + let (tip, section) = match rendered.split_once(&format!("\n{ANCHOR}")) { + Some((t, s)) => (t.trim_end(), format!("{ANCHOR}{}", s.trim_end())), + None => ("", rendered.trim_end().to_string()), + }; + + // The section goes before the first level-2 heading, above its own anchor + // and attribute lines; with none it is appended. + let at = sections.first().map_or(lines.len(), |&h| { + let mut s = h; + while s > 0 && (lines[s - 1].starts_with('[') || lines[s - 1].starts_with("//")) { + s -= 1; + } + s + }); + let head = header_end(&lines).min(at); + + let mut out: Vec = Vec::with_capacity(lines.len() + 80); + out.extend(lines[..head].iter().map(|l| l.to_string())); + if !tip.is_empty() { + pad(&mut out); + out.push(tip.to_string()); + out.push(String::new()); + } + out.extend(lines[head..at].iter().map(|l| l.to_string())); + pad(&mut out); + out.push(section); + out.push(String::new()); + out.extend(lines[at..].iter().map(|l| l.to_string())); + let mut s = out.join("\n"); + while s.contains("\n\n\n") { + s = s.replace("\n\n\n", "\n\n"); + } + if !s.ends_with('\n') { + s.push('\n'); + } + Some((s, "AI-install section inserted")) +} + +/// Join lines with newlines and append a final newline. +fn join(lines: &[&str]) -> String { + let mut s = lines.join("\n"); + s.push('\n'); + s +} + +/// Ensure the output ends with a blank line before a new block. +fn pad(out: &mut Vec) { + if out.last().is_some_and(|l| !l.trim().is_empty()) { + out.push(String::new()); + } +} + +/// Indices of `== ` headings outside delimited blocks. +fn level2_headings(lines: &[&str]) -> Vec { + let mut open: Option<&str> = None; + let mut v = Vec::new(); + for (i, l) in lines.iter().enumerate() { + let t = l.trim_end(); + let delim = t.len() >= 4 + && ["-", ".", "=", "*", "+", "_", "/"] + .iter() + .any(|c| t.chars().all(|x| x.to_string() == *c)) + || t == "```" + || t.starts_with("|==="); + if delim { + match open { + Some(o) if o == t => open = None, + None => open = Some(t), + _ => {} + } + continue; + } + if open.is_none() && t.starts_with("== ") { + v.push(i); + } + } + v +} + +/// The line after the document header (`= Title` and the attribute/author lines +/// that follow it up to the first blank line); 0 when there is no title. +fn header_end(lines: &[&str]) -> usize { + let Some(t) = lines.iter().position(|l| l.starts_with("= ")) else { + return 0; + }; + lines[t..] + .iter() + .position(|l| l.trim().is_empty()) + .map_or(lines.len(), |p| t + p) +} + +#[cfg(test)] +mod tests { + use super::*; + + const R: &str = "[TIP]\n====\nsay it\n====\n\n[[ai-install]]\n== AI-Assisted Installation (Recommended)\n\nbody\n"; + + /// Verify installation content surrounds the introduction correctly and reinsertion is a no-op. + #[test] + fn tip_after_header_section_before_first_heading() { + let src = "// SPDX\n= Title\n:toc:\n\nIntro.\n\n[#usage]\n== Usage\n\n----\n== not a heading\n----\n"; + let (out, _) = merged(src, R).unwrap(); + let tip = out.find("[TIP]").unwrap(); + let intro = out.find("Intro.").unwrap(); + let anchor = out.find("[[ai-install]]").unwrap(); + let usage = out.find("[#usage]").unwrap(); + assert!( + out.find(":toc:").unwrap() < tip && tip < intro && intro < anchor && anchor < usage + ); + assert!(!out.contains("\n\n\n")); + assert!(merged(&out, R).is_none(), "second run must be a no-op"); + } + + /// Verify an existing installation section gains only its missing anchor. + #[test] + fn an_existing_section_only_gains_the_anchor() { + let src = "= T\n\n== AI-Assisted Installation\n\nSay X.\n"; + let (out, what) = merged(src, R).unwrap(); + assert_eq!( + out, + "= T\n\n[[ai-install]]\n== AI-Assisted Installation\n\nSay X.\n" + ); + assert!(what.contains("anchor")); + } + + /// Verify headings inside literal blocks do not prevent appending the installation section. + #[test] + fn headings_inside_blocks_are_ignored_and_no_heading_appends() { + let src = "= T\n\n....\n== x\n....\n"; + let (out, _) = merged(src, R).unwrap(); + assert!(out.trim_end().ends_with("body")); + } +} diff --git a/crates/launcher-common/tests/fixtures/provisioning/check-repairs/guix.scm b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/guix.scm new file mode 100644 index 0000000..4ff25e3 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/guix.scm @@ -0,0 +1,45 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +;; +;; guix.scm — docsr as a Guix package. +;; +;; guix build -f guix.scm # installs the source tree to share/docsr +;; guix shell -D -f guix.scm # the full development toolchain +;; +;; This is a SOURCE package, and says so: a hermetic compiled build needs this +;; repository's docs dependencies packaged in Guix, which they are not +;; (Guix builds offline). The toolchain below is real — `guix shell -D -f guix.scm` +;; then `just setup` gives a working environment. See docs/SETUP.adoc §Guix. +(use-modules (guix packages) (guix gexp) + (guix build-system copy) + (gnu packages) + ((guix licenses) #:prefix license:)) + +;; The repository root: this file sits at the root or in build/ (PROVISIONING-STANDARD §1). +(define %source-dir + (let ((d (dirname (current-filename)))) + (if (string=? (basename d) "build") (dirname d) d))) +;; A spec may name an output ("rust:cargo"); plain specification->package cannot. +(define (spec->input spec) + (call-with-values (lambda () (specification->package+output spec)) + (lambda (pkg out) (if (string=? out "out") pkg (list pkg out))))) + +(define %ignored + '(".git" "target" ".eval" "node_modules" "_build" "deps" "zig-out" ".zig-cache" "dist-newstyle")) + +(package + (name "docsr") + (version "0.1.0") + (source (local-file %source-dir "docsr-checkout" + #:recursive? #t + #:select? (lambda (file stat) + (not (member (basename file) %ignored))))) + (build-system copy-build-system) + (arguments (list #:install-plan #~'(("." "share/docsr/")))) + (native-inputs (map spec->input (list "git" "bash" "coreutils" "nss-certs" + "just" "mise" "shellcheck" + "ruby-asciidoctor"))) + (home-page "https://github.com/hyperpolymath/docsr") + (synopsis "docsr, a hyperpolymath repository") + (description "docsr, a hyperpolymath repository.") + (license license:mpl2.0)) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check-repairs/mise.lock b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/mise.lock new file mode 100644 index 0000000..7c777d0 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check-repairs/mise.lock @@ -0,0 +1,79 @@ +# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html + +[[tools.just]] +version = "1.56.0" +backend = "aqua:casey/just" + +[tools.just."platforms.linux-arm64"] +checksum = "sha256:c8c1d656e9f47569ec1ae2bf8779af2621cdeea6bbbba3b0cacd64f951d25e2b" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074960" + +[tools.just."platforms.linux-arm64-musl"] +checksum = "sha256:c8c1d656e9f47569ec1ae2bf8779af2621cdeea6bbbba3b0cacd64f951d25e2b" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074960" + +[tools.just."platforms.linux-x64"] +checksum = "sha256:fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074624" + +[tools.just."platforms.linux-x64-musl"] +checksum = "sha256:fa2a8ec1015d9df5330941ade12437488fc40d33f9c9f8cd4eb70a26de11b639" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074624" + +[tools.just."platforms.macos-arm64"] +checksum = "sha256:f35798d4bcdc4db020eef7d2853ad98bbfb97a4d29ee695ba042f18e7fedcc11" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074635" + +[tools.just."platforms.macos-x64"] +checksum = "sha256:09b35ff6d17023ffae37ce408d1a78a976d9e001cae54b88e238f7f40db9b783" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472074650" + +[tools.just."platforms.windows-x64"] +checksum = "sha256:804f5b2fe94291d0df38fd8dfc5620afbf3496f8f11c1915b42a87323234f0ba" +url = "https://github.com/casey/just/releases/download/1.56.0/just-1.56.0-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/casey/just/releases/assets/472075917" + +[[tools.shellcheck]] +version = "0.11.0" +backend = "aqua:koalaman/shellcheck" + +[tools.shellcheck."platforms.linux-arm64"] +checksum = "sha256:12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056934" + +[tools.shellcheck."platforms.linux-arm64-musl"] +checksum = "sha256:12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056934" + +[tools.shellcheck."platforms.linux-x64"] +checksum = "sha256:8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056942" + +[tools.shellcheck."platforms.linux-x64-musl"] +checksum = "sha256:8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056942" + +[tools.shellcheck."platforms.macos-arm64"] +checksum = "sha256:56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.aarch64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056932" + +[tools.shellcheck."platforms.macos-x64"] +checksum = "sha256:3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.darwin.x86_64.tar.xz" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056930" + +[tools.shellcheck."platforms.windows-x64"] +checksum = "sha256:8a4e35ab0b331c85d73567b12f2a444df187f483e5079ceffa6bda1faa2e740e" +url = "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.zip" +url_api = "https://api.github.com/repos/koalaman/shellcheck/releases/assets/279056944" diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/Justfile b/crates/launcher-common/tests/fixtures/provisioning/check/Justfile new file mode 100644 index 0000000..ab56fa4 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/Justfile @@ -0,0 +1,20 @@ +mod provision 'build/just/provision.just' +default: + @just --list +setup: provision::setup +doctor: provision::doctor +heal: provision::heal +dev-shell: provision::dev-shell +toolchain-refresh: provision::toolchain-refresh +ai-setup: provision::ai-setup +eval: provision::eval +config-show: provision::config-show +opsm: provision::opsm +build: provision::build +test: provision::test +bench: provision::bench +lint: provision::lint +fmt: provision::fmt +run: provision::run +deps: provision::deps +ai-warmup who="user": (provision::ai-warmup who) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/README.adoc b/crates/launcher-common/tests/fixtures/provisioning/check/README.adoc new file mode 100644 index 0000000..e97d20e --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/README.adoc @@ -0,0 +1,8 @@ += X + +[[ai-install]] +== AI-Assisted Installation +Say it. + +== Other +__NOT_OURS__ diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/channels.scm b/crates/launcher-common/tests/fixtures/provisioning/check/channels.scm new file mode 100644 index 0000000..54ac61a --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/channels.scm @@ -0,0 +1,18 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; channels.scm — the Guix revision this repository's guix.scm and manifest.scm +;; were verified against. Reproduce that exact Guix with: +;; +;; guix time-machine -C channels.scm -- shell -m manifest.scm +;; +;; Refresh it (after re-verifying) with: just toolchain-refresh +;; Canon pin, verified 2026-09-30 (docker.io/metacall/guix, guix describe). +(list (channel + (name 'guix) + (url "https://codeberg.org/guix/guix.git") + (branch "master") + (commit "ae77aeb9543de2661d739104bc4d3803d8c6f38b") + (introduction + (make-channel-introduction + "9edb3f66fd807b096b48283debdcddccfea34bad" + (openpgp-fingerprint + "BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA"))))) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/guix.scm b/crates/launcher-common/tests/fixtures/provisioning/check/guix.scm new file mode 100644 index 0000000..e399098 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/guix.scm @@ -0,0 +1,2 @@ +(use-modules (guix packages)) +(package (name "x") (source (local-file "."))) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/launcher.sh b/crates/launcher-common/tests/fixtures/provisioning/check/launcher.sh new file mode 100755 index 0000000..1bb1320 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/launcher.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 test +# +# @launcher-deed begin +# ;; SPDX-License-Identifier: MPL-2.0 +# (praxis-deed +# :schema-version "1.0.0" +# :canonical-name "x-launcher" +# :beholding-chora #u5"estate/chora" +# (artefact :type "launcher" :version "0.1.0" +# :generator "provision-set") +# (app :name "x" :display "x" +# :url "https://github.com/x" :archetype "x") +# (compliance :standard-version "0.5.0" +# :standards ("launcher-standard.adoc" +# "PROVISIONING-STANDARD.adoc")) +# (modes :accepted ("--setup" "--doctor" "--heal" "--ai-setup" "--help" "--version" +# "--start" "--stop" "--status" "--auto" "--integ" "--disinteg")) +# (platforms :supported ("linux" "macos" "windows")) +# (lifecycle-phases :covered ("provision" "diagnose" "heal") +# :deferred ("install" "run"))) +# @launcher-deed end +# +# The launcher for a library / tool / theory / docs repository +# (launcher-standard 0.5.0, archetype profile). Every mode is implemented in +# build/just/provision-modes.sh, and every provisioning mode delegates to the +# Justfile. This file is minted by `provision-set`; do not hand-edit it. +# Repo-specific facts belong in .machine_readable/descriptiles/provisioning.deed. +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +if [ ! -f "$REPO_DIR/build/just/provision-modes.sh" ]; then + echo "launcher.sh: build/just/provision-modes.sh is missing — this checkout is incomplete." >&2 + echo "Re-clone, or restore it: git checkout -- build/just/provision-modes.sh" >&2 + exit 1 +fi +# shellcheck source=build/just/provision-modes.sh +. "$REPO_DIR/build/just/provision-modes.sh" + +hp_launcher_main "$@" diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/manifest.scm b/crates/launcher-common/tests/fixtures/provisioning/check/manifest.scm new file mode 100644 index 0000000..fa68547 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/manifest.scm @@ -0,0 +1 @@ +(specifications->manifest (list "git")) diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/mise.lock b/crates/launcher-common/tests/fixtures/provisioning/check/mise.lock new file mode 100644 index 0000000..e69de29 diff --git a/crates/launcher-common/tests/fixtures/provisioning/check/mise.toml b/crates/launcher-common/tests/fixtures/provisioning/check/mise.toml new file mode 100644 index 0000000..394fe2d --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/check/mise.toml @@ -0,0 +1,5 @@ +[settings] +lockfile = true +[tools] +just = "latest" +shellcheck = "latest" diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/docsr/README.adoc b/crates/launcher-common/tests/fixtures/provisioning/lang/docsr/README.adoc new file mode 100644 index 0000000..c8e289c --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/docsr/README.adoc @@ -0,0 +1 @@ += Hi diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/idr/Justfile b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/Justfile new file mode 100644 index 0000000..8b135a4 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/Justfile @@ -0,0 +1,2 @@ +doctor: + @echo FAKE-GREEN diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/idr/idr.ipkg b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/idr.ipkg new file mode 100644 index 0000000..6071d68 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/idr.ipkg @@ -0,0 +1,3 @@ +package idr +sourcedir = "src" +modules = Idr diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/idr/src/Idr.idr b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/src/Idr.idr new file mode 100644 index 0000000..29ce4a5 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/idr/src/Idr.idr @@ -0,0 +1,5 @@ +module Idr + +export +x : Nat +x = 1 diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/.gitignore b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.lock b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.lock new file mode 100644 index 0000000..838ca11 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.lock @@ -0,0 +1,16 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "rustd" +version = "0.1.0" +dependencies = [ + "itoa", +] diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.toml b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.toml new file mode 100644 index 0000000..0b0ab9c --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "rustd" +version = "0.1.0" +edition = "2024" + +[dependencies] +itoa = "1" diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Justfile b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Justfile new file mode 100644 index 0000000..698e179 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/Justfile @@ -0,0 +1,7 @@ +# Run with extra args, e.g. just run --verbose +# cargo run -- {{args}} +run *args: + cargo run -- {{args}} + +setup: + @echo own-setup diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/deno.json b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/deno.json new file mode 100644 index 0000000..e69de29 diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs new file mode 100644 index 0000000..8cc876f --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustd/src/main.rs @@ -0,0 +1,2 @@ +/// Provide a minimal Rust executable for the mixed-language detection fixture. +fn main() { let mut b = itoa::Buffer::new(); println!("{}", b.format(42)); } diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/.gitignore b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.lock b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.lock new file mode 100644 index 0000000..105605a --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "rustr" +version = "0.1.0" diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.toml b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.toml new file mode 100644 index 0000000..436ac9d --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "rustr" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Justfile b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Justfile new file mode 100644 index 0000000..3e0afd6 --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/Justfile @@ -0,0 +1,6 @@ +# Build the binary +build: + cargo build + +doctor: + @echo own-doctor diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/deno.json b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/deno.json new file mode 100644 index 0000000..e69de29 diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs new file mode 100644 index 0000000..a5aeaae --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/rustr/src/main.rs @@ -0,0 +1,4 @@ +/// Run the Rust fixture executable used by provisioning language detection. +fn main() { + println!("Hello, world!"); +} diff --git a/crates/launcher-common/tests/fixtures/provisioning/lang/srcc/README b/crates/launcher-common/tests/fixtures/provisioning/lang/srcc/README new file mode 100644 index 0000000..587be6b --- /dev/null +++ b/crates/launcher-common/tests/fixtures/provisioning/lang/srcc/README @@ -0,0 +1 @@ +x diff --git a/crates/launcher-common/tests/provisioning_fixtures.rs b/crates/launcher-common/tests/provisioning_fixtures.rs new file mode 100644 index 0000000..19387a4 --- /dev/null +++ b/crates/launcher-common/tests/provisioning_fixtures.rs @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The provisioning fixtures, driven through the real engine. +//! +//! `check/` is a broken repository with exactly three faults. Each repair in +//! `check-repairs/` must remove its own FAIL and no other (kill the mutant), +//! and all three together must bring `provision-check.sh` to rc 0 — the +//! positive control, without which "exactly three FAILs" could be a checker +//! that fails everything. +//! +//! `lang/` holds one small repository per detection case: `langs` must name +//! the right language, the deno leftovers must raise PV-W30 (and stop raising +//! it once removed), and an offline mint must merge each custom Justfile +//! recipe into its `-local` twin. +//! +//! The engine is bash and needs `just` and `git`: a missing tool fails these +//! tests loudly, because a skipped conformance test is not a pass. + +use launch_scaffolder_common::provisioning::{ + canon::{Canon, ENGINE_FILES}, + mint::{self, Act, Options}, +}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/provisioning"); + +/// The canon engine library, which the `lang/` fixtures do not carry. +const LIB: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../standards/provisioning/templates/build/just/provision-lib.sh" +); + +/// The three faults planted in `check/`, as `provision-check.sh` words them. +const CHECK_FAILS: [&str; 3] = [ + "root recipe missing: fmt-check", + "mise.lock is empty (latest is not concrete; run: mise lock)", + "guix.scm: no package field: version build-system home-page synopsis description license", +]; + +/// Copy fixture `rel` to a fresh directory named `tag` under cargo's test +/// tmpdir, and make it a git repository (the engine takes the repo name from +/// git, so an un-initialised copy would report the enclosing checkout). +/// +/// The `check` fixture carries no engine of its own: the baked canon engine is +/// written in here, so the test always exercises the engine the binary ships +/// rather than a committed copy that could drift from it. +fn scratch(rel: &str, tag: &str) -> PathBuf { + let dst = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("provisioning-{tag}")); + let _ = std::fs::remove_dir_all(&dst); + let ok = Command::new("cp") + .arg("-r") + .arg(Path::new(FIXTURES).join(rel)) + .arg(&dst) + .status() + .expect("cp") + .success(); + assert!(ok, "copying fixture {rel}"); + if rel == "check" { + for e in ENGINE_FILES { + let f = dst.join(e); + std::fs::create_dir_all(f.parent().unwrap()).unwrap(); + std::fs::write(&f, Canon::Baked.file(e).unwrap()).unwrap(); + } + } + let ok = Command::new("git") + .args(["init", "-q"]) + .current_dir(&dst) + .status() + .expect("git is required by the provisioning engine") + .success(); + assert!(ok, "git init in {}", dst.display()); + dst +} + +/// Run the repository's own `provision-check.sh --dev`; return its exit code +/// and the set of FAIL messages it printed. +fn provision_check(repo: &Path) -> (i32, BTreeSet) { + let out = Command::new("bash") + .args(["build/just/provision-check.sh", "--dev", "."]) + .current_dir(repo) + .output() + .expect("bash"); + let stdout = String::from_utf8_lossy(&out.stdout); + let fails = stdout + .lines() + .filter_map(|l| l.trim().strip_prefix("FAIL")) + .map(|l| l.trim().to_string()) + .collect(); + let code = out + .status + .code() + .expect("provision-check.sh killed by a signal"); + (code, fails) +} + +/// Run a verb of the canon `provision-lib.sh` against `repo`; return stdout +/// and stderr together. +fn lib(repo: &Path, verb: &str) -> String { + let out = Command::new("bash") + .arg(LIB) + .arg(verb) + .env("PROVISION_ROOT", repo) + .current_dir(repo) + .output() + .expect("bash"); + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +/// The FAIL set expected once the faults in `fixed` have been repaired. +fn expected_without(fixed: &[usize]) -> BTreeSet { + CHECK_FAILS + .iter() + .enumerate() + .filter(|(i, _)| !fixed.contains(i)) + .map(|(_, f)| f.to_string()) + .collect() +} + +/// Apply repair `i` (an index into [`CHECK_FAILS`]) to `repo`. +fn repair(repo: &Path, i: usize) { + let repairs = Path::new(FIXTURES).join("check-repairs"); + match i { + 0 => { + let jf = repo.join("Justfile"); + let mut s = std::fs::read_to_string(&jf).unwrap(); + s.push_str("fmt-check: provision::fmt-check\n"); + std::fs::write(jf, s).unwrap(); + } + 1 => { + std::fs::copy(repairs.join("mise.lock"), repo.join("mise.lock")).unwrap(); + } + 2 => { + std::fs::copy(repairs.join("guix.scm"), repo.join("guix.scm")).unwrap(); + } + _ => unreachable!(), + } +} + +/// Verify engine language detection for documentation, Idris, and Rust fixtures. +#[test] +fn langs_names_each_fixture_language() { + for (fixture, lang) in [ + ("docsr", "docs"), + ("idr", "idris2"), + ("rustd", "rust"), + ("rustr", "rust"), + ("srcc", "docs"), + ] { + let repo = scratch(&format!("lang/{fixture}"), &format!("langs-{fixture}")); + assert_eq!(lib(&repo, "langs").trim(), lang, "{fixture}"); + } +} + +/// Verify the Deno-leftover warning disappears when deno.json is removed from Rust fixtures. +#[test] +fn doctor_warns_on_deno_leftovers_and_only_then() { + for fixture in ["rustd", "rustr"] { + let repo = scratch(&format!("lang/{fixture}"), &format!("deno-{fixture}")); + assert!( + lib(&repo, "doctor").contains("PV-W30 deno.json"), + "{fixture}" + ); + std::fs::remove_file(repo.join("deno.json")).unwrap(); + assert!( + !lib(&repo, "doctor").contains("PV-W30"), + "{fixture} without deno.json" + ); + } +} + +/// The tests that run `provision-check.sh` or `just --summary`, so need +/// `just` >= 1.42 on PATH. The estate `rust-ci` reusable has no `just` and +/// skips this module by name (`rust-ci.yml`); `launcher-artefacts.yml` +/// installs `just` and runs it. +mod needs_just { + use super::*; + + /// Verify the broken fixture fails with exactly the three planted conformance faults. + #[test] + fn check_fixture_fails_on_exactly_its_three_faults() { + let repo = scratch("check", "check"); + let (code, fails) = provision_check(&repo); + assert_eq!(code, 1); + assert_eq!(fails, expected_without(&[])); + } + + /// Verify each targeted repair removes its own finding while the other faults still fail. + #[test] + fn each_repair_removes_only_its_own_fail() { + for i in 0..CHECK_FAILS.len() { + let repo = scratch("check", &format!("check-repair-{i}")); + repair(&repo, i); + let (code, fails) = provision_check(&repo); + assert_eq!(code, 1, "repair {i}: two faults remain"); + assert_eq!(fails, expected_without(&[i]), "repair {i}"); + } + } + + /// Verify repairing every planted fault yields a successful check with no failures. + #[test] + fn all_repairs_together_pass() { + let repo = scratch("check", "check-repaired"); + for i in 0..CHECK_FAILS.len() { + repair(&repo, i); + } + let (code, fails) = provision_check(&repo); + assert!(fails.is_empty(), "{fails:?}"); + assert_eq!(code, 0); + } + + /// Verify offline mint preserves all mise configs when either contains non-tool settings. + #[test] + fn offline_mint_retains_mise_configs_with_non_tool_settings() { + for (i, (primary, secondary)) in [ + ( + Some("[tools]\nzig = '0.14'\n[env]\nMODE = 'dev'\n"), + "[tools]\nrust = '1.95'\n", + ), + ( + Some("[tools]\nzig = '0.14'\n"), + "[tools]\nrust = '1.95'\n[tasks.build]\nrun = 'echo build'\n", + ), + (None, "[settings]\nexperimental = true\n"), + ( + Some("[tools]\npython = '3'\n[env]\nMODE = 'dev'\n"), + "[tools]\nrust = '1.95'\n", + ), + ] + .into_iter() + .enumerate() + { + let repo = scratch("lang/idr", &format!("mise-settings-{i}")); + std::fs::copy( + concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"), + repo.join("LICENSE"), + ) + .unwrap(); + if let Some(text) = primary { + std::fs::write(repo.join("mise.toml"), text).unwrap(); + } + std::fs::write(repo.join(".mise.toml"), secondary).unwrap(); + std::fs::write(repo.join(".tool-versions"), "just 1.56.0\n").unwrap(); + let report = mint::mint( + &repo, + &Canon::Baked, + &Options { + offline: true, + ..Options::default() + }, + ) + .unwrap(); + for file in ["mise.toml", ".mise.toml", ".tool-versions"] { + assert!( + report.files.iter().any(|(p, a)| p == file + && matches!(a, Act::Skipped(w) if w.contains("outside [tools]"))), + "{file}: {:?}", + report.files + ); + } + assert_eq!( + std::fs::read_to_string(repo.join("mise.toml")) + .ok() + .as_deref(), + primary + ); + assert_eq!( + std::fs::read_to_string(repo.join(".mise.toml")).unwrap(), + secondary + ); + assert_eq!( + std::fs::read_to_string(repo.join(".tool-versions")).unwrap(), + "just 1.56.0\n" + ); + std::fs::remove_dir_all(repo).unwrap(); + } + } + + /// Verify offline mint folds tool-only configs, preserves winning pins, and removes banned tools. + #[test] + fn offline_mint_folds_tool_only_configs_and_still_replaces_banned_tools() { + for (i, (banned, secondary)) in [(false, true), (true, true), (true, false)] + .into_iter() + .enumerate() + { + let repo = scratch("lang/idr", &format!("mise-tools-{i}")); + std::fs::copy( + concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"), + repo.join("LICENSE"), + ) + .unwrap(); + let primary = if banned { + "[tools]\npython = '3'\nzig = '0.14'\n" + } else { + "[tools]\nzig = '0.14'\n" + }; + std::fs::write(repo.join("mise.toml"), primary).unwrap(); + if secondary { + std::fs::write(repo.join(".mise.toml"), "[tools]\nzig = '0.15'\n").unwrap(); + } + let report = mint::mint( + &repo, + &Canon::Baked, + &Options { + offline: true, + ..Options::default() + }, + ) + .unwrap(); + assert!( + report + .files + .iter() + .any(|(p, a)| p == "mise.toml" && matches!(a, Act::Replaced(_))), + "{:?}", + report.files + ); + let text = std::fs::read_to_string(repo.join("mise.toml")).unwrap(); + let config: toml::Table = toml::from_str(&text).unwrap(); + let tools = config["tools"].as_table().unwrap(); + assert!(!tools.contains_key("python")); + assert_eq!( + tools["zig"].as_str(), + Some(if secondary { "0.15" } else { "0.14" }) + ); + if secondary { + assert!( + report + .files + .iter() + .any(|(p, a)| p == ".mise.toml" && matches!(a, Act::Removed(_))), + "{:?}", + report.files + ); + assert!(!repo.join(".mise.toml").exists()); + } + std::fs::remove_dir_all(repo).unwrap(); + } + } + + /// Verify offline mint preserves custom recipes as local twins and reports the skipped mise lock. + #[test] + fn offline_mint_keeps_custom_recipes_as_local_twins() { + let canon = Canon::resolve(None).unwrap(); + let licence = concat!(env!("CARGO_MANIFEST_DIR"), "/../../LICENSES/MPL-2.0.txt"); + for (fixture, verb) in [("idr", "doctor"), ("rustd", "setup"), ("rustr", "doctor")] { + let repo = scratch(&format!("lang/{fixture}"), &format!("mint-{fixture}")); + std::fs::copy(licence, repo.join("LICENSE")).unwrap(); + let opts = Options { + repo: Some(format!("hyperpolymath/{fixture}")), + year: Some(2026), + offline: true, + ..Options::default() + }; + let report = mint::mint(&repo, &canon, &opts).unwrap(); + let act = |path: &str| { + report + .files + .iter() + .find(|(p, _)| p == path) + .map(|(_, a)| a.clone()) + .unwrap_or_else(|| panic!("{fixture}: no report line for {path}")) + }; + assert!( + matches!(act("Justfile"), Act::Replaced(ref w) if w.contains(&format!("{verb}-local"))), + "{fixture}: {}", + act("Justfile") + ); + assert!(matches!(act("mise.lock"), Act::Skipped(_)), "{fixture}"); + let jf = std::fs::read_to_string(repo.join("Justfile")).unwrap(); + assert!(jf.contains(&format!("{verb}-local")), "{fixture}"); + assert!( + jf.contains(&format!("{verb}: provision::{verb}")), + "{fixture}" + ); + } + } +} diff --git a/crates/launcher/src/cmd_provision_set.rs b/crates/launcher/src/cmd_provision_set.rs new file mode 100644 index 0000000..8c11857 --- /dev/null +++ b/crates/launcher/src/cmd_provision_set.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) Jonathan D.A. Jewell +//! `provision-set` subcommand — mint, realign and check a repository's +//! provisioning set against `PROVISIONING-STANDARD.adoc`. + +use anyhow::Result; +use clap::{Args as ClapArgs, Subcommand}; +use launch_scaffolder_common::provisioning::{ + canon::{CANON_ENV, Canon}, + check, + licence::Refusal, + mint::{self, Act, Options}, +}; +use std::path::PathBuf; + +#[derive(Debug, ClapArgs)] +pub struct Args { + /// Use a canon directory (a `CANON` file and a `templates/` tree) instead + /// of the canon baked into the binary. + #[arg(long, value_name = "DIR", env = CANON_ENV, global = true)] + canon: Option, + + #[command(subcommand)] + action: Action, +} + +#[derive(Debug, Subcommand)] +enum Action { + /// Fail if the engine files differ from the canon; otherwise run the + /// repository's provision-check.sh and exit with its code. + Check { + /// Downgrade unfilled repository-specific slots to warnings. + #[arg(long)] + dev: bool, + /// Repository to check. + #[arg(default_value = ".")] + target: PathBuf, + }, + /// Write the provisioning set: engine files realigned, minted files + /// created when missing and replaced only while they are stubs. + Mint(MintArgs), + /// The same operation as `mint`, named for an existing repository. + Realign(MintArgs), +} + +#[derive(Debug, ClapArgs)] +struct MintArgs { + /// `owner/name` (default: the `origin` remote). + #[arg(long)] + repo: Option, + /// app | library | tool | theory | docs (default: the deed's, else inferred). + #[arg(long)] + archetype: Option, + /// Copyright year (default: SOURCE_DATE_EPOCH, else this year). + #[arg(long)] + year: Option, + /// Skip `mise lock` and `guix import crate` (both need the network). + #[arg(long)] + offline: bool, + /// Repository to provision. + #[arg(default_value = ".")] + target: PathBuf, +} + +/// Exit code for a licence refusal (standard §6): a ledger line, not a crash. +pub const EXIT_REFUSED: i32 = 3; + +/// Run `provision-set`: check a repository, or mint/realign its provisioning +/// set. Check mode exits 1 on engine drift; otherwise it exits with the +/// repository checker's code (1 if killed by a signal). Mint/realign exits 3 +/// on a licence refusal and 4 when the report contains a failed external step. +/// Other canon, check or mint errors are returned to the caller; a mint with +/// no reported failures returns successfully, even if some files were skipped. +pub fn run(args: Args) -> Result<()> { + let canon = Canon::resolve(args.canon.as_deref())?; + match args.action { + Action::Check { dev, target } => { + let drift = check::engine_drift(&target, &canon)?; + if !drift.is_empty() { + eprintln!( + "provision-set check: engine differs from {} — run `launch-scaffolder provision-set realign`:", + canon.reference()? + ); + for d in &drift { + eprintln!(" FAIL {d}"); + } + std::process::exit(1); + } + let code = check::conformance(&target, dev)?; + std::process::exit(code); + } + Action::Mint(m) | Action::Realign(m) => { + let opts = Options { + repo: m.repo, + archetype: m.archetype, + year: m.year, + offline: m.offline, + }; + match mint::mint(&m.target, &canon, &opts) { + Ok(r) => { + println!( + "{} — {} (docs {}), archetype {}, languages {}", + r.slug, + r.licence.code, + r.licence.doc, + r.archetype, + r.langs.join(", ") + ); + if let Some(from) = &r.inherited_from { + println!(" inherited set from {from}: re-minted"); + } + if let Some(why) = r.licence.unratified { + println!(" UNRATIFIED doc licence: {why}"); + } + for (path, act) in &r.files { + let tag = match act { + Act::Created => "create", + Act::Replaced(_) => "replace", + Act::Kept(_) => "keep", + Act::Skipped(_) => "SKIP", + Act::Removed(_) => "remove", + Act::Failed(_) => "FAIL", + }; + println!(" {tag:<8} {path}: {act}"); + } + if r.files.iter().any(|(_, a)| matches!(a, Act::Failed(_))) { + std::process::exit(mint::EXIT_EXTERNAL); + } + Ok(()) + } + Err(e) => match e.downcast_ref::() { + Some(refusal) => { + eprintln!("provision-set: refused: {refusal}"); + std::process::exit(EXIT_REFUSED); + } + None => Err(e), + }, + } + } + } +} diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 841fd2f..072799d 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -20,6 +20,7 @@ use clap::{Parser, Subcommand}; mod cmd_config; mod cmd_mint; mod cmd_provision; +mod cmd_provision_set; mod cmd_realign; mod cmd_standard; @@ -60,6 +61,11 @@ enum Command { /// Install (--integ) or uninstall (--disinteg) a launcher on the current system. Provision(cmd_provision::Args), + /// Mint, realign or check the repository provisioning set + /// (guix, mise, Justfile, launcher, setup and AI-install docs). + #[command(name = "provision-set")] + ProvisionSet(cmd_provision_set::Args), + /// Get, set, or validate the config section of an existing launcher. Config(cmd_config::Args), @@ -71,6 +77,7 @@ enum Command { Standard(cmd_standard::Args), } +/// Parse CLI arguments, initialise tracing, and dispatch the selected launcher command. fn main() -> Result<()> { let cli = Cli::parse(); @@ -91,6 +98,7 @@ fn main() -> Result<()> { match cli.command { Command::Mint(args) => cmd_mint::run(args, cli.standard.as_deref()), Command::Provision(args) => cmd_provision::run(args, cli.standard.as_deref()), + Command::ProvisionSet(args) => cmd_provision_set::run(args), Command::Config(args) => cmd_config::run(args, cli.standard.as_deref()), Command::Realign(args) => cmd_realign::run(args, cli.standard.as_deref()), Command::Standard(args) => cmd_standard::run(args, cli.standard.as_deref()), diff --git a/standards/provisioning/CANON b/standards/provisioning/CANON new file mode 100644 index 0000000..8ccf41a --- /dev/null +++ b/standards/provisioning/CANON @@ -0,0 +1 @@ +standards@bf7c97abfc7240e9c4275c85153e74b50de10eaf+local-docstrings diff --git a/standards/provisioning/PROVISIONING-STANDARD.adoc b/standards/provisioning/PROVISIONING-STANDARD.adoc new file mode 100644 index 0000000..8975116 --- /dev/null +++ b/standards/provisioning/PROVISIONING-STANDARD.adoc @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Provisioning Standard +:toc: +:toclevels: 2 +:revdate: 2026-09-30 +:revnumber: 1.0.0 + +*Question this answers:* what must every repository carry so that nobody who +clones it ever has to search for how to install, configure, run, test, benchmark, +diagnose or repair it? + +Machine-readable counterpart: link:provisioning-standard_praxis.deed[provisioning-standard_praxis.deed]. +Templates: link:templates/[templates/]. Launcher modes: `docs/UX-standards/launcher-standard.adoc` +§Provisioning modes (v0.6.0). + +== 1. The provisioning set + +Every repository in `hyperpolymath/*` and `metadatastician/*` carries the files +below, except forks, archived repositories, `007` and the two vaults +(`dev-notes-vault`, `memory-vault`). + +[cols="3,1,4"] +|=== +|File |Kind |Purpose + +|`launcher.sh` |generated |`--setup`, `--doctor`, `--heal`, `--ai-setup`, plus runtime modes for the `app` archetype +|`build/just/provision.just` |engine |the `provision::` just module: every verb below +|`build/just/provision-lib.sh` |engine |the implementation of every verb (bash only, shellcheck-clean) +|`build/just/provision-modes.sh` |engine |the launcher's provisioning dispatch +|`channels.scm` |minted |the pinned Guix commit (placed beside `guix.scm`, see below). Copied from the canon at mint, then re-pinned per repository by `toolchain-refresh`, so it is checked for a 40-hex commit pin, never byte-compared with the canon +|`Justfile` |merged |`mod provision` plus root delegations; the repo's own recipes are kept +|`mise.toml` + `mise.lock` |minted |the toolchain: `latest` in `mise.toml`, made concrete and checksummed in `mise.lock` +|`guix.scm`, `manifest.scm` |minted |the Guix package and development shell (at the root, or all three under `build/` where the repository already keeps `build/guix.scm`) +|`build/guix/crates.scm` |generated |Rust repositories: one Guix origin per registry crate in `Cargo.lock`, and `%crate-inputs` for `guix.scm`. Written by `guix import crate --lockfile` through `provision-lib.sh crates-scm`; never hand-edited +|`.machine_readable/descriptiles/provisioning_praxis.deed` |minted |this repo's facts: archetype, languages, verb overrides, system deps, ports, config +|`docs/SETUP.adoc` |minted |the complete manual route, with the doctor-code troubleshooting table +|`docs/AI_INSTALLATION_GUIDE.adoc` |minted |the steps an AI assistant follows +|`llm-warmup-{user,dev,maintainer}.adoc` |minted |paste-in context for any AI, one per audience (at the root, or in `docs/onboarding/` or `docs/` where the repository already keeps them) +|README `\[[ai-install]]` section |inserted |"Just say it": one sentence to give any AI +|=== + +*Placement.* A file sits at the root only when something needs it there: +`launcher.sh`, `Justfile`, `mise.toml` and `mise.lock` are found by the tools that +read them. The Guix trio and the warm-ups follow the layout the repository already +has, so `rsr-template-repo` keeps `build/guix.scm` and +`docs/onboarding/llm-warmup-*.adoc`. One resolver in `provision-lib.sh` decides +this (`provision-lib.sh guix-dir` and `set-files`), and doctor, `dev-shell`, +`toolchain-refresh` and `provision-check.sh` all ask it, so no two of them can +disagree about where a file lives. Both `guix.scm` and `build/guix.scm` present is +PV-W35. + +*Engine* files are identical estate-wide and `provision-set realign` overwrites +them. The *generated* `launcher.sh` is rendered from the template and the repo's +deed. Realign re-renders it only when its header carries the `@launcher-deed` +block, which marks it as generated. A hand-written launcher is kept, and gets +the provisioning modes by sourcing `build/just/provision-modes.sh`. The template +renders the launcher for `library`, `tool`, `theory` and `docs`; an `app` launcher, +which needs its runtime modes, is rendered by `launch-scaffolder mint` from the +app's own config and sources the same file. + +A minted set whose deed `:repo` differs from the checkout's own slug was +*inherited* (for example from `rsr-template-repo` through GitHub's "Use this +template"). Realign treats inherited files as stubs and re-mints them, so a +new repository is never born describing its template. *Minted* files are written once, then owned by the repository: realign +creates them when missing and replaces them only while they are still stubs +(doctor PV-W24–W28), never once filled. One exception: a `mise.toml` that names a +banned tool (PV-W23) is replaced too, and its other `[tools]` entries are carried +over, because a repository cannot own a toolchain the estate has banned. The Justfile is merged: an existing +custom `doctor`, `setup` or `heal` recipe is renamed `doctor-local`, +`setup-local` or `heal-local`, and the canon verbs run it (§3). + +== 2. The verbs + +`just ` and `just provision::` are the same. `./launcher.sh --setup`, +`--doctor`, `--heal` and `--ai-setup` call the engine directly, so a root recipe +of the same name can never shadow them. + +[cols="1,4"] +|=== +|Verb |Contract + +|`setup` |`mise install`, the repo's dependencies per language, `setup-local`, then `doctor` +|`doctor` |PASS / WARN / FAIL for every requirement, each with a code and its fix; the last line is `: N PASS, N WARN, N FAIL`; exits non-zero if and only if something FAILed +|`heal` |applies every fix marked *auto*, runs `heal-local`, then `doctor` +|`dev-shell` |`guix shell -m /manifest.scm` when Guix is present, otherwise the mise environment +|`toolchain-refresh` |`mise up --bump`, re-lock `mise.lock`, re-pin only the `guix` channel commit in `channels.scm` (left unchanged, with a WARN, when the current commit cannot be read), regenerate `build/guix/crates.scm` from `Cargo.lock` when `guix.scm` loads it (the TRUST-DEFAULTS-POLICY recipe). The crate file is replaced only when the importer defined every registry crate; otherwise PV-E41 and the old file stays +|`build`, `test`, `bench`, `lint`, `fmt`, `fmt-check`, `run`, `deps` |the deed's override, else the language default, else an explicit "N/A for this archetype" and exit 0; nothing is faked. A language default runs only when what it needs exists (a `test` step in `build.zig`, test files for `bun test`, which exits 0 on none) +|`eval` |`test` + `bench` with timings, saved under `.eval/` +|`config-show` |where the toolchain and configuration are declared +|`ai-setup` |prints the "Just say it" sentence, read from the first listing block of the README's `\[[ai-install]]` section so it is written in one place, and the guide path +|`ai-warmup ` |prints that warm-up for pasting into any AI +|`opsm` |how to fetch this repository with OPSM, and how to get OPSM +|`langs`, `search`, `version` |discovery helpers +|=== + +Archetypes (`app`, `library`, `tool`, `theory`, `docs`) come from the deed. Only +`app` has runtime modes (start/stop/status); the others say N/A. + +== 3. Repository-specific checks + +A repository keeps its own checks as root recipes named `doctor-local`, +`setup-local` and `heal-local`, or as scripts `build/just/{doctor,setup,heal}-local.sh`. +The canon verbs run both. A failing `doctor-local` is a FAIL (PV-E50), so it turns +`just doctor`, `./launcher.sh --doctor` and CI red together. +A `build/just/doctor-local.sh` runs sourced in a subshell, so an `exit` or a +tripped `set -e` in it cannot end the doctor early; it is reported as FAIL (PV-E51) +and the checks it did finish still count. + +The estate's pre-existing boilerplate doctors (the `rsr-diag` and +`toolchain-check` families, 326 of 346 measured on 2026-09-30) print `[FAIL]` +and exit 0; they are replaced by `doctor: provision::doctor`, not renamed. + +== 4. Toolchain + +* *mise*: tools at `latest`, `[settings] lockfile = true`; `mise.lock` pins each + to a version and per-platform checksum. The base set is `just` and `shellcheck`; + each detected language adds its own (rust, zig, julia, erlang+elixir, + erlang+gleam, opam, bun, lychee for docs). Banned tools (python, deno, node, + npm, yarn, go, java, make) never appear. +* *Guix*: `channels.scm` pins the commit; `manifest.scm` lists the development + shell. Rust repositories get a `cargo-build-system` `guix.scm`; every other + ecosystem gets a *source* package (`copy-build-system`) that says so, because a + hermetic compiled build needs that ecosystem's dependencies packaged in Guix, + which they are not. A spec may name an output (`rust:cargo`). + At commit ae77aeb Guix does not package idris2, gleam, bun or lychee. For those + tools the Guix shell provides mise. +* *just ≥ 1.42*: a root recipe depending on a module recipe + (`doctor: provision::doctor`) fails on 1.31, 1.36, 1.40 and 1.41 and works from + 1.42 (measured 2026-09-30). Doctor enforces the floor as PV-E02. +* *bun* is the only JavaScript runtime. Leftover deno, Nix, Makefile, Python or + npm files are WARNs (PV-W30–W34), not FAILs. Each deno repository gets one + migration issue. + +== 5. Template slots + +Templates carry two tiers of placeholder: + +* `+__X__+`: mechanical, filled by the generator from evidence (name, slug, + languages, tools, licence, copyright holder). +* `+__SPEC_X__+`: repository-specific prose (what it is, the questions to ask, + privacy notice, verification, usage, uninstall, architecture, gotchas, + release, CI). This is written by reading the repository. + +`provision-set check` treats any `+__X__+` residue as a hard failure. `+__SPEC_X__+` +residue is a hard failure at the pre-PR gate. In a development checkout, doctor +reports it as PV-W29 instead. AsciiDoc renders a stray `+__X__+` as italics, so +residue is visible as garbled text. + +The pipeline order is fixed: *mint → specialise → verify → PR*. + +== 6. Licence of minted files + +Minted files carry an SPDX header from birth. That is authoring, not +relicensing. The header matches the repository's classification in +`3-practice/LICENCE-POLICY.adoc`: + +* code and config (`+__LICENSE__+`) are MPL-2.0 for sole-owner repos, AGPL-3.0-or-later for the son-shared repos, and PMPL-1.0-or-later only for the register; +* prose docs (`+__DOC_LICENSE__+`) are CC-BY-SA-4.0 in sole-owner repos. + +When the generator cannot classify a repository from evidence, it refuses to mint +and records the repository in the campaign ledger. It never guesses, never +rewrites an existing header, and never touches forks. The engine files are +estate-wide and stay MPL-2.0 wherever they are vendored. + +== 7. OPSM + +Every `docs/SETUP.adoc` has an OPSM section with +`opsm install https://github.com/.git --registry git` and the steps to build +OPSM itself. Repositories without a registry package are fetched through OPSM's +git adapter, and the section says so. + +== 8. Conformance + +A repository conforms when, on a fresh clone: + +. `./launcher.sh --help` and `--version` exit 0; +. `just --list` parses and lists every verb in §2; +. `mise.toml` names no banned tool, and `mise.lock` gives every `mise.toml` tool a + concrete version and carries `sha256` checksums; +. `guix.scm`, `manifest.scm` and `channels.scm` are not stubs. The test is positive: + `guix.scm` must define every package field (name, version, source, build-system, + home-page, synopsis, description, licence), `manifest.scm` must list + specifications and `channels.scm` must pin a 40-hex commit; +. no template residue remains (§5); +. `just doctor` exits 0 once `just setup` has run. + +`templates/build/just/provision-check.sh` checks items 1–5 without network access. +It and doctor ask the same predicates in `provision-lib.sh` (`mise-banned`, +`mise-lock-gaps`, `guix-stub`), so the CI gate and `just doctor` cannot disagree. diff --git a/standards/provisioning/provisioning-standard_praxis.deed b/standards/provisioning/provisioning-standard_praxis.deed new file mode 100644 index 0000000..14a2e5a --- /dev/null +++ b/standards/provisioning/provisioning-standard_praxis.deed @@ -0,0 +1,137 @@ +;; SPDX-FileCopyrightText: © 2026 Jonathan D.A. Jewell (hyperpolymath) +;; SPDX-License-Identifier: MPL-2.0 +;; +;; provisioning-standard_praxis.deed — what every repository carries so that +;; nobody who clones it has to search for how to set it up, run it, test it, +;; diagnose it or repair it. +;; +;; Prose counterpart: 3-practice/provisioning/PROVISIONING-STANDARD.adoc +;; Launcher modes: launcher/launcher-standard_praxis.deed (provisioning-modes) +;; Templates: 3-practice/provisioning/templates/ +;; Offline checker: templates/build/just/provision-check.sh +(praxis-deed + :schema-version "1.0.0" + :canonical-name "provisioning-standard" + :beholding-chora #u5"estate/chora" + :standard-version "1.0.0" + :standard-date "2026-09-30" + :compliance ("PROVISIONING-STANDARD.adoc" "launcher-standard.adoc" + "TRUST-DEFAULTS-POLICY.adoc" "LICENCE-POLICY.adoc") + + ;; ------------------------------------------------------------------ scope + (scope + :orgs ("hyperpolymath" "metadatastician") + :exclude ("forks" "archived" "007" "dev-notes-vault" "memory-vault")) + + ;; ------------------------------------------------------------ artefact set + ;; engine = identical estate-wide; `provision-set realign` overwrites it. + ;; generated = produced, never hand-edited. launcher.sh is rendered from the + ;; template + deed and re-rendered by realign only when it carries + ;; the @launcher-deed block (hand-written ones are kept and source + ;; build/just/provision-modes.sh); build/guix/crates.scm is + ;; written from Cargo.lock by mint and toolchain-refresh. + ;; minted = written once, then owned by the repository; realign creates it + ;; when missing and replaces it only while it is still a stub, or + ;; when the deed's :repo is another repository's (inherited). + ;; merged = the repository's own content is kept; the canon is added. + (artefacts + (file :path "launcher.sh" :kind generated :mode "100755") + (file :path "build/just/provision.just" :kind engine) + (file :path "build/just/provision-lib.sh" :kind engine :mode "100755") + (file :path "build/just/provision-modes.sh" :kind engine :mode "100755") + (file :path "build/just/provision-check.sh" :kind engine :mode "100755") + (file :path "channels.scm" :kind minted) ; re-pinned by toolchain-refresh + (file :path "build/guix/crates.scm" :kind generated) ; from Cargo.lock, when guix.scm loads it + (file :path "Justfile" :kind merged) + (file :path "mise.toml" :kind minted) + (file :path "mise.lock" :kind minted) + (file :path "guix.scm" :kind minted) + (file :path "manifest.scm" :kind minted) + (file :path ".machine_readable/descriptiles/provisioning_praxis.deed" :kind minted) + (file :path "docs/SETUP.adoc" :kind minted) + (file :path "docs/AI_INSTALLATION_GUIDE.adoc" :kind minted) + (file :path "llm-warmup-user.adoc" :kind minted) + (file :path "llm-warmup-dev.adoc" :kind minted) + (file :path "llm-warmup-maintainer.adoc" :kind minted) + ;; placement: the Guix trio lives beside guix.scm (root, or build/ when the + ;; repository already has build/guix.scm); warm-ups at the root, or in + ;; docs/onboarding/ or docs/ when already kept there. Resolved by + ;; `provision-lib.sh guix-dir` / `set-files`, shared by doctor and check. + (placement :root-required ("launcher.sh" "Justfile" "mise.toml" "mise.lock") + :guix-dir ("." "build") :warmup-dir ("." "docs/onboarding" "docs")) + (section :file "README.adoc" :anchor "ai-install" :kind merged)) + + ;; ------------------------------------------------------------------- verbs + ;; Every verb exists both as a root recipe and as provision::. + ;; Launcher modes call the engine directly, so no root recipe can shadow them. + (verbs + :provisioning ("setup" "doctor" "heal" "dev-shell" "toolchain-refresh") + :lifecycle ("build" "test" "bench" "eval" "lint" "fmt" "fmt-check" "run" "deps") + :guidance ("ai-setup" "ai-warmup" "config-show" "opsm")) + (launcher-modes + (mode :flag "--setup" :verb "setup") + (mode :flag "--doctor" :verb "doctor") + (mode :flag "--heal" :verb "heal") + (mode :flag "--ai-setup" :verb "ai-setup")) + ;; A repository's own checks: root recipes -local, or scripts + ;; build/just/-local.sh. A failing doctor-local is FAIL PV-E50. + ;; A doctor-local.sh that exits (or trips set -e) before its end is FAIL PV-E51. + (local-hooks :verbs ("doctor" "setup" "heal") :suffix "-local") + (not-applicable :behaviour "print why and exit 0; never fake a result") + + (archetypes + (archetype :name app :runtime-modes #t) + (archetype :name library :runtime-modes #f) + (archetype :name tool :runtime-modes #f) + (archetype :name theory :runtime-modes #f) + (archetype :name docs :runtime-modes #f)) + + ;; --------------------------------------------------------------- toolchain + (toolchain + :mise-versions "latest" + :mise-lockfile #t + :mise-base ("just" "shellcheck") + :js-runtime "bun" + ;; One list with BANNED_TOOLS in build/just/provision-lib.sh (launch-scaffolder + ;; tests that they agree). A tool installed through an npm:, pipx:, pip: or go: + ;; backend is banned whatever its name; mise.toml, .mise.toml and + ;; .tool-versions are all read (doctor PV-W23). + :banned-tools ("python" "deno" "denojs" "node" "nodejs" "npm" "yarn" "pnpm" + "typescript" "rescript" "make" "black" "ruff" "pip" "poetry" + "nix" "go" "golang" "java" "kotlin") + :banned-backends ("npm" "pipx" "pip" "go") + ;; Measured 2026-09-30: a root recipe depending on a module recipe fails on + ;; just 1.31, 1.36, 1.40, 1.41 and works from 1.42. + :just-floor "1.42.0" + :guix-channels "channels.scm" + :guix-package (rust "cargo-build-system" other "copy-build-system source package") + :guix-gaps ("idris2" "gleam" "bun" "lychee")) + + ;; ------------------------------------------------------------- slot tiers + (slots + (tier :name mechanical :pattern "__X__" :filled-by "provision-set mint" + :residue fail) + (tier :name specific :pattern "__SPEC_X__" :filled-by "per-repository specialisation" + :residue fail :residue-in-dev-checkout "warn PV-W29")) + (pipeline :order ("mint" "specialise" "verify" "pr")) + + ;; ----------------------------------------------------------------- licence + ;; Minted files carry SPDX from birth, chosen by the repository's + ;; classification in LICENCE-POLICY.adoc. Unclassifiable = refuse to mint. + (licence + :code-slot "__LICENSE__" + :doc-slot "__DOC_LICENSE__" + :sole ("MPL-2.0" "CC-BY-SA-4.0") + :son-shared ("AGPL-3.0-or-later" "AGPL-3.0-or-later") + :register-only "PMPL-1.0-or-later" + :unclassifiable "refuse and record in the campaign ledger" + :existing-headers "never rewritten") + + ;; ----------------------------------------------------------- doctor codes + ;; Full table: templates/docs/SETUP.adoc.tmpl §Troubleshooting. + (doctor + :summary-line "{name}: N PASS, N WARN, N FAIL" + :exit "non-zero iff any FAIL" + :fail-codes ("PV-E01" "PV-E02" "PV-E03" "PV-E10" "PV-E11" "PV-E20" "PV-E27" "PV-E40" "PV-E41" "PV-E50" "PV-E51") + :warn-codes ("PV-W01" "PV-W20" "PV-W21" "PV-W22" "PV-W23" "PV-W24" "PV-W25" "PV-W26" + "PV-W27" "PV-W28" "PV-W29" "PV-W30" "PV-W31" "PV-W32" "PV-W33" "PV-W34" "PV-W35"))) diff --git a/standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl b/standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl new file mode 100644 index 0000000..5428097 --- /dev/null +++ b/standards/provisioning/templates/.machine_readable/descriptiles/provisioning_praxis.deed.tmpl @@ -0,0 +1,32 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; provisioning_praxis.deed: the repo-specific facts behind `just setup/doctor/run/…`. +;; build/just/provision-lib.sh reads each `:key "value"` field below. Rules: +;; * one key per line, value in double quotes, and no double quote inside a value +;; (put anything that needs quoting in build/just/setup-local.sh or a script); +;; * an empty value "" means "use the per-language default"; +;; * a verb key (build test bench lint fmt fmt-check run deps) replaces the default command. +;; Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +(praxis-deed + :schema-version "1.0.0" + :canonical-name "__APP_NAME__-provisioning" + :repo "__REPO_SLUG__" + :archetype "__ARCHETYPE__" ; app | library | tool | theory | docs + :languages "__LANGS__" ; detected; informational + ;; Contract-verb overrides ("" = per-language default, which `just langs` shows) + :build "" + :test "" + :bench "" + :lint "" + :fmt "" + :fmt-check "" + :run "" + :deps "" + ;; Facts shown by `just config-show` and checked by `just doctor` + :config "" ; where runtime configuration lives + :ports "" ; ports an app listens on (estate: no 8080-class) + :system-deps "" ; OS commands that must exist, space-separated + ;; What `just ai-setup` and `just opsm` print ("" = the generic line) + :ai-say-it "" + :opsm-note "") diff --git a/standards/provisioning/templates/Justfile.tmpl b/standards/provisioning/templates/Justfile.tmpl new file mode 100644 index 0000000..779138e --- /dev/null +++ b/standards/provisioning/templates/Justfile.tmpl @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: __LICENSE__ +# SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +# +# __APP_NAME__ — every task is a recipe here; `just` lists them. +# The provisioning contract (setup doctor heal eval dev-shell toolchain-refresh +# ai-setup ai-warmup config-show opsm) comes from build/just/provision.just, and +# the per-language defaults for build/test/bench/lint/fmt/fmt-check/run from the same +# module. Define a recipe here to override one for this repository. +# Needs just >= 1.42 (a root recipe depending on a module recipe, e.g. `doctor: provision::doctor`). + +mod provision 'build/just/provision.just' + +# List every recipe +default: + @just --list --list-submodules + +__DELEGATIONS__ diff --git a/standards/provisioning/templates/README-ai-install.adoc.tmpl b/standards/provisioning/templates/README-ai-install.adoc.tmpl new file mode 100644 index 0000000..ddc6d1b --- /dev/null +++ b/standards/provisioning/templates/README-ai-install.adoc.tmpl @@ -0,0 +1,65 @@ +[TIP] +==== +*AI-assisted install:* tell any AI assistant + +`Set up __APP_NAME__ from https://github.com/__REPO_SLUG__` + +It reads this repository, asks a few questions and does the rest. <>. +==== + +[[ai-install]] +== AI-Assisted Installation (Recommended) + +=== Just say it + +You do not need to read the rest of this README. Say this to any AI assistant +that can read a URL and run commands (or give you commands to paste): + +[source,text] +---- +Set up __APP_NAME__ from https://github.com/__REPO_SLUG__ +---- + +The URL is the key: it leads the assistant to +link:docs/AI_INSTALLATION_GUIDE.adoc[`docs/AI_INSTALLATION_GUIDE.adoc`], the +complete step-by-step recipe for this repository. The assistant checks your +system, installs the prerequisites, fetches and sets up __APP_NAME__, and verifies +it with `just doctor`. + +=== Other ways to say it + +* "Install https://github.com/__REPO_SLUG__ for me" +* "Get __APP_NAME__ working on my machine — https://github.com/__REPO_SLUG__" +* `./launcher.sh --ai-setup` (or `just ai-setup`) prints the line and copies it to your clipboard. + +=== What you will be asked + +. Your operating system (Linux, macOS, or Windows via WSL2). +. Where to put it (default `~/src/__APP_NAME__`). +. To confirm the privacy notice below. +__SPEC_QUESTIONS__ + +=== Privacy and security + +[IMPORTANT] +==== +Setting up __APP_NAME__ clones this repository and installs its developer tools +per-user with mise (no administrator rights, nothing sent anywhere). + +__SPEC_PRIVACY__ +==== + +=== After install + +__SPEC_USAGE__ + +=== Uninstall + +Tell your assistant "Uninstall __APP_NAME__", or delete the checkout +(`rm -rf ~/src/__APP_NAME__`). Shared tools are removed with `mise uninstall`. + +=== Troubleshooting + +Run `just doctor`; each FAIL carries a code explained in +link:docs/SETUP.adoc#troubleshooting[`docs/SETUP.adoc` §Troubleshooting], and +`just heal` applies the automatic fixes. Prefer to do it yourself? The full +manual route is link:docs/SETUP.adoc[`docs/SETUP.adoc`]; developers and +maintainers can brief an AI with `just ai-warmup dev` / `just ai-warmup maintainer`. diff --git a/standards/provisioning/templates/build/just/provision-check.sh b/standards/provisioning/templates/build/just/provision-check.sh new file mode 100755 index 0000000..651aa92 --- /dev/null +++ b/standards/provisioning/templates/build/just/provision-check.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision-check.sh — offline conformance check for the provisioning set +# (PROVISIONING-STANDARD.adoc §8, items 1–5). No network, no installs. +# +# provision-check.sh [--dev] [REPO_DIR] +# +# Exit 0 = conforms, 1 = does not. Every failure is printed; none is skipped. +# --dev downgrades repository-specific slot residue (__SPEC_X__) to a warning, +# for a checkout mid-specialisation. Mechanical residue (__X__) always fails. +set -uo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +LIB="$HERE/provision-lib.sh" +DEV=0 +[ "${1:-}" = "--dev" ] && { DEV=1; shift; } +REPO="${1:-.}" +cd "$REPO" || { echo "provision-check: no such directory: $REPO" >&2; exit 1; } + +MIN_JUST="1.42.0" +VERBS="setup doctor heal dev-shell toolchain-refresh ai-setup ai-warmup eval config-show opsm build test bench lint fmt fmt-check run deps" +# Files the provisioning set owns; residue anywhere else is not ours to judge. +# The engine scripts under build/just/ are excluded: they name slot syntax in their +# own comments and code, and realign owns them. README.adoc is scanned only inside +# its [[ai-install]] section. +# Their locations come from provision-lib.sh (guix-dir, set-files): the engine and +# this check must never disagree about where the Guix files or warm-ups live. +[ -f "$LIB" ] || { echo "provision-check: engine missing: $LIB" >&2; exit 1; } +# Run an engine verb with this checkout as PROVISION_ROOT, forwarding its exit status. +lib() { PROVISION_ROOT="$PWD" bash "$LIB" "$@"; } +GDIR=$(lib guix-dir) +SET_FILES=$(lib set-files) +# Print the path to Guix file $1 using the directory reported by the engine. +gp() { [ "$GDIR" = . ] && echo "$1" || echo "$GDIR/$1"; } + +fails=0 warns=0 +# Print a successful conformance diagnostic without changing the failure tally. +ok() { printf ' ok %s\n' "$*"; } +# Print a failed conformance diagnostic and increment the failure tally. +bad() { printf ' FAIL %s\n' "$*"; fails=$((fails + 1)); } +# Print a conformance warning and increment the warning tally. +warn() { printf ' warn %s\n' "$*"; warns=$((warns + 1)); } + +# Return success when version $1 sorts at or above version $2 with sort -V. +version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } + +echo "[1] launcher" +if [ ! -f launcher.sh ]; then bad "launcher.sh missing" +elif [ ! -x launcher.sh ]; then bad "launcher.sh is not executable (chmod +x; committed mode must be 100755)" +else + for m in --help --version; do + if ./launcher.sh "$m" >/dev/null 2>&1; then ok "launcher.sh $m exits 0" + else bad "launcher.sh $m exits non-zero"; fi + done +fi + +echo "[2] just" +f0=$fails +if ! command -v just >/dev/null 2>&1; then bad "just is not installed; cannot check the recipe contract" +else + jv=$(just --version 2>/dev/null | awk '{print $2}') + if version_ge "$jv" "$MIN_JUST"; then ok "just $jv >= $MIN_JUST" + else bad "just $jv < $MIN_JUST (module-recipe dependencies do not resolve)"; fi + if ! summary=$(just --summary 2>&1); then + bad "the Justfile does not parse: $(printf '%s' "$summary" | head -1)" + else + have=" $summary " + for v in $VERBS; do + case "$have" in *" $v "*) ;; *) bad "root recipe missing: $v" ;; esac + case "$have" in *" provision::$v "*) ;; *) bad "module recipe missing: provision::$v (engine drift)" ;; esac + done + [ "$fails" -eq "$f0" ] && ok "all ${VERBS// /, } present at root and in provision::" + fi +fi + +echo "[3] mise" +if [ ! -f mise.toml ]; then bad "mise.toml missing" +else + if hit=$(lib mise-banned); then ok "mise.toml names no banned tool"; else bad "mise.toml names banned tools: $hit"; fi + [ -f .mise.toml ] && bad "both mise.toml and .mise.toml exist" + if lg=$(lib mise-lock-gaps); then ok "mise.lock pins every mise.toml tool"; else bad "$lg (latest is not concrete; run: mise lock)"; fi +fi + +echo "[4] guix" +for g in "$(gp guix.scm)" "$(gp manifest.scm)" "$(gp channels.scm)"; do + if r=$(lib guix-stub "$g"); then ok "$g is not a stub"; else bad "$g: $r"; fi +done + +echo "[5] template residue" +f0=$fails +# Inspect stdin for template slots labelled by $1; --dev downgrades only repository slots. +residue() { # $1 label; stdin = the text to judge + local text mech spec + text=$(cat) + # Mechanical slots (__X__ not starting SPEC_) are the generator's job: always fatal. + mech=$(printf '%s\n' "$text" | grep -noE '__[A-Z][A-Z_]*__' | grep -v ':__SPEC_' | head -3 | tr '\n' ' ') + spec=$(printf '%s\n' "$text" | grep -noE '__SPEC_[A-Z_]*__' | head -3 | tr '\n' ' ') + [ -n "$mech" ] && bad "$1: unfilled mechanical slots: $mech" + if [ -n "$spec" ]; then + if [ "$DEV" = 1 ]; then warn "$1: unfilled repository slots: $spec" + else bad "$1: unfilled repository slots: $spec"; fi + fi +} +for f in $SET_FILES; do + # shellcheck disable=SC2094 # $f is only the label; nothing writes it + [ -f "$f" ] && residue "$f" < "$f" +done +for r in README.adoc README.md; do + [ -f "$r" ] || continue + # From the [[ai-install]] anchor to the next level-2 heading after its own. + residue "$r [[ai-install]]" < <(awk '/^\[\[ai-install\]\]/{on=1; n=0} on && /^== /{n++; if (n>1) exit} on' "$r") +done +[ "$fails" -eq "$f0" ] && ok "no residue that fails this mode" + +echo +echo "provision-check: $fails FAIL, $warns WARN" +[ "$fails" -eq 0 ] diff --git a/standards/provisioning/templates/build/just/provision-lib.sh b/standards/provisioning/templates/build/just/provision-lib.sh new file mode 100755 index 0000000..abca94c --- /dev/null +++ b/standards/provisioning/templates/build/just/provision-lib.sh @@ -0,0 +1,929 @@ +#!/usr/bin/env bash +# SC2015: `A && pass || fail` is safe here — pass/warn/fail/info always return 0. +# shellcheck disable=SC2015 +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision-lib.sh — the shared provisioning engine behind build/just/provision.just +# +# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +# Vendored into every repo at build/just/provision-lib.sh by `provision-set mint`. +# DO NOT hand-edit a vendored copy: repo-specific facts live in +# .machine_readable/descriptiles/provisioning_praxis.deed, and `provision-set realign` +# overwrites this file from canon. +# +# Usage: provision-lib.sh [args] +# verbs: langs doctor setup heal dev-shell toolchain-refresh crates-scm ai-setup +# ai-warmup eval config-show opsm lang-run +# search version +# facts: guix-specs mise-tools langs guix-dir set-files guix-gaps tool-table +# system-deps +# predicates (print why and exit 1, or exit 0 silently): +# guix-stub FILE, mise-lock-gaps, mise-banned +# +# Exit: 0 ok | 1 a FAIL was found / a step failed | 2 usage error +set -uo pipefail + +PROVISION_LIB_VERSION="0.5.0" +ROOT="${PROVISION_ROOT:-$(pwd)}" +cd "$ROOT" || exit 2 +DEED=".machine_readable/descriptiles/provisioning_praxis.deed" +MIN_JUST="1.42.0" # root→module recipe deps (`doctor: provision::doctor`); 1.41 rejects them +# How to get mise: package managers first; the installer only as download, read, run. +MISE_INSTALL_HINT="brew install mise | Fedora: dnf copr enable jdxcode/mise, dnf install mise | winget install jdx.mise | others: https://mise.jdx.dev/installing-mise.html — or: curl -fsSLo mise-install.sh https://mise.run, read it, sh mise-install.sh" +T="timeout 15" # some --version probes hang (observed 2026-09-30); never probe unbounded + +if [ -t 1 ]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; B=$'\033[34m'; Z=$'\033[0m'; else R=; G=; Y=; B=; Z=; fi +PASS=0; WARN=0; FAIL=0 +# Increment the PASS tally and print the supplied diagnostic. +pass() { PASS=$((PASS+1)); printf ' %sPASS%s %s\n' "$G" "$Z" "$*"; } +# Increment the WARN tally and print the supplied diagnostic. +warn() { WARN=$((WARN+1)); printf ' %sWARN%s %s\n' "$Y" "$Z" "$*"; } +# Increment the FAIL tally and print the supplied diagnostic. +fail() { FAIL=$((FAIL+1)); printf ' %sFAIL%s %s\n' "$R" "$Z" "$*"; } +# Print an informational diagnostic without changing the tallies. +info() { printf ' %sinfo%s %s\n' "$B" "$Z" "$*"; } +# Print the supplied text as a diagnostic section heading. +hdr() { printf '\n%s== %s ==%s\n' "$B" "$*" "$Z"; } +# Return success when the command named by $1 is available. +have() { command -v "$1" >/dev/null 2>&1; } + +# Where this repository keeps each part of the set. A file sits at the root only +# when something needs it there (launcher.sh, Justfile, mise.toml, mise.lock); +# the Guix trio and the warm-ups follow the layout the repository already has +# (rsr-template-repo keeps build/guix.scm and docs/onboarding/llm-warmup-*.adoc). +# This is the ONE answer: provision-check.sh asks it through `guix-dir` and +# `set-files` rather than keeping its own copy. +guix_dir() { if [ -f build/guix.scm ]; then echo build; else echo .; fi; } +# Print the first existing warm-up directory, falling back to the repository root. +warmup_dir() { + local d + for d in . docs/onboarding docs; do + compgen -G "$d/llm-warmup-*.adoc" >/dev/null && { echo "$d"; return; } + done + echo . +} +# Print the path to Guix file $1 in the repository-selected Guix directory. +gpath() { local d; d=$(guix_dir); [ "$d" = . ] && echo "$1" || echo "$d/$1"; } +# Print the warm-up path for audience $1 in the selected warm-up directory. +wpath() { local d; d=$(warmup_dir); [ "$d" = . ] && echo "llm-warmup-$1.adoc" || echo "$d/llm-warmup-$1.adoc"; } +# List the provisioning-owned paths inspected for unfilled template slots. +set_files() { + printf '%s\n' launcher.sh Justfile justfile mise.toml \ + "$(gpath guix.scm)" "$(gpath manifest.scm)" "$(gpath channels.scm)" \ + "$DEED" docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc \ + "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)" +} + +# --------------------------------------------------------------------------- +# Descriptor: flat `:key "value"` reads from provisioning_praxis.deed (s-expression). +deed() { # $1 key, $2 default + local v="" + [ -f "$DEED" ] && v=$(grep -oE "\(:?$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') + [ -z "$v" ] && v=$(grep -oE ":$1[[:space:]]+\"[^\"]*\"" "$DEED" 2>/dev/null | head -1 | sed -E 's/^[^"]*"//; s/"$//') + printf '%s' "${v:-$2}" +} +# Derive the repository slug from origin, falling back to hyperpolymath/. +repo_slug() { + local u; u=$(git config --get remote.origin.url 2>/dev/null || true) + u=${u%.git}; u=${u#*github.com[:/]} + [ -n "$u" ] && printf '%s' "$u" || printf 'hyperpolymath/%s' "$(basename "$ROOT")" +} +REPO_SLUG="$(deed repo "$(repo_slug)")" +REPO_NAME="${REPO_SLUG#*/}" +ARCHETYPE="$(deed archetype "")" + +# --------------------------------------------------------------------------- +# Language detection. Markers at the root or one level down (workspaces). The +# estate ABI/FFI pattern nests deeper: Idris2 to two levels down (src/abi/*.ipkg), +# Zig to three (ffi/zig/build.zig, and rsr-template-repo's src/interface/ffi/build.zig: +# 74 repos have their only build.zig at that depth, measured 2026-09-30). +# Order matters only for display. `docs` is reported when nothing else is. +first() { find . -maxdepth "${2:-2}" -not -path './.git/*' -not -path '*/node_modules/*' -not -path './target/*' -name "$1" -print -quit 2>/dev/null; } +# Print languages found through project markers, or docs when none are detected. +detect_langs() { + local out=() + [ -n "$(first Cargo.toml)" ] && out+=(rust) + [ -n "$(first '*.ipkg' 3)" ] && out+=(idris2) + [ -n "$(first Project.toml 1)" ] && out+=(julia) + [ -n "$(first build.zig 4)" ] && out+=(zig) + [ -n "$(first mix.exs)" ] && out+=(elixir) + [ -n "$(first gleam.toml)" ] && out+=(gleam) + [ -n "$(first dune-project)" ] && out+=(ocaml) + { [ -n "$(first '*.cabal')" ] || [ -n "$(first stack.yaml 1)" ]; } && out+=(haskell) + [ -n "$(first package.json)" ] && out+=(bun) + [ ${#out[@]} -eq 0 ] && out+=(docs) + printf '%s\n' "${out[@]}" +} +mapfile -t LANGS < <(detect_langs) +[ -z "$ARCHETYPE" ] && { [ "${LANGS[*]}" = docs ] && ARCHETYPE=docs || ARCHETYPE=library; } + +# Binaries each language needs, and how each is obtained. +# mise registry gaps (verified 2026-09-30 against mise 2026.7.5): ocaml, haskell, +# idris2 and guile are NOT mise tools. They come from opam / ghcup / pack / the +# system package manager (or Guix), and the doctor probes the binary itself. +lang_tools() { + case "$1" in + rust) echo "cargo rustc" ;; + idris2) echo "idris2" ;; + julia) echo "julia" ;; + zig) echo "zig" ;; + elixir) echo "elixir mix erl" ;; + gleam) echo "gleam erl" ;; + ocaml) echo "opam dune ocaml" ;; + haskell) echo "ghc cabal" ;; + bun) echo "bun" ;; + docs) echo "" ;; + esac +} +# Guix package specs per language (verified 2026-09-30 against guix ae77aeb in +# docker.io/metacall/guix). Guix has NO idris2, gleam, bun or lychee, and its +# julia is 1.8.5: those come from mise, which Guix itself ships ("mise"). +GUIX_BASE="git bash coreutils nss-certs just mise shellcheck" +# Print the Guix package specifications needed for language $1. +lang_guix() { + case "$1" in + rust) echo "rust rust:cargo gcc-toolchain pkg-config" ;; + idris2) echo "chez-scheme gmp gcc-toolchain" ;; + zig) echo "zig" ;; + elixir) echo "elixir erlang" ;; + gleam) echo "erlang" ;; + ocaml) echo "ocaml dune opam ocaml-findlib" ;; + haskell) echo "ghc cabal-install" ;; + docs) echo "ruby-asciidoctor" ;; + julia|bun) echo "" ;; + esac +} +# Tools a Guix shell cannot supply, so mise (inside that shell) does. +lang_guix_gap() { + case "$1" in + idris2) echo "idris2 (via pack)" ;; julia) echo julia ;; gleam) echo gleam ;; bun) echo bun ;; docs) echo lychee ;; + esac +} +# Print deduplicated base and language-specific Guix package specifications. +guix_specs() { + local l s=" $GUIX_BASE " + for l in "${LANGS[@]}"; do for p in $(lang_guix "$l"); do case "$s" in *" $p "*) ;; *) s="$s$p ";; esac; done; done + echo "$s" | xargs +} + +# mise tools per language (registry-checked 2026-09-30, mise 2026.7.5). idris2 comes +# from pack and haskell from ghcup or Guix: neither is a mise tool. The recipes +# themselves need just and shellcheck, so those two are always declared. +MISE_BASE="just shellcheck" +# Tools a repo's own recipes call (e.g. `deps-audit` runs trivy): pinned only where used. +RECIPE_TOOLS="trivy" +# Print the mise tools available for language $1; unsupported languages emit nothing. +lang_mise() { case "$1" in + rust) echo "rust" ;; zig) echo "zig" ;; julia) echo "julia" ;; + elixir) echo "erlang elixir" ;; gleam) echo "erlang gleam" ;; ocaml) echo "opam" ;; + bun) echo "bun" ;; docs) echo "lychee" ;; esac; } +# Print known optional tools referenced by uncommented Justfile recipe text. +recipe_tools() { + local t f body="" + for f in Justfile justfile build/just/*.just; do + [ -f "$f" ] && body+=$(grep -v '^[[:space:]]*#' "$f")$'\n' + done + for t in $RECIPE_TOOLS; do + [[ "$body" =~ (^|[^[:alnum:]_-])$t([^[:alnum:]_-]|$) ]] && echo "$t" + done +} + +# Print deduplicated mise tools required by the base, languages and recipes. +mise_tools() { + local l t seen=" " out=() + for t in $MISE_BASE $(for l in "${LANGS[@]}"; do lang_mise "$l"; done) $(recipe_tools); do + case "$seen" in *" $t "*) ;; *) out+=("$t"); seen="$seen$t " ;; esac + done + printf "%s\n" "${out[*]}" +} + +# Print installation guidance for the toolchain of language $1. +lang_remedy() { + case "$1" in + rust) echo "mise use rust@latest (or rustup: https://rustup.rs)" ;; + idris2) echo "install pack: https://github.com/stefan-hoeck/idris2-pack#installation, then: pack install-app idris2" ;; + julia) echo "mise use julia@latest (or: https://julialang.org/install/ — juliaup)" ;; + zig) echo "mise use zig@latest" ;; + elixir) echo "mise use erlang@latest elixir@latest (erlang builds from source: allow ~10 min)" ;; + gleam) echo "mise use gleam@latest erlang@latest" ;; + ocaml) echo "mise use opam@latest && opam init -y && opam switch create . --deps-only -y" ;; + haskell) echo "ghcup: https://www.haskell.org/ghcup/ (or: just dev-shell, which uses Guix)" ;; + bun) echo "mise use bun@latest" ;; + esac +} + +# The per-language default for each contract verb. A repo overrides any of +# these simply by defining the root recipe itself; `provision-set` only adds +# `: provision::` where the root Justfile has no such recipe. +lang_cmd() { # $1 lang, $2 verb -> prints a shell command, or nothing (= N/A) + local l=$1 v=$2 ipkg + case "$l:$v" in + rust:deps) echo "cargo fetch" ;; + rust:build) echo "cargo build --all-targets" ;; + rust:test) echo "cargo test --all-targets" ;; + rust:bench) grep -rqs '\[\[bench\]\]\|criterion\|divan' --include=Cargo.toml . && echo "cargo bench" ;; + rust:lint) echo "cargo clippy --all-targets -- -D warnings" ;; + rust:fmt) echo "cargo fmt --all" ;; + rust:fmt-check) echo "cargo fmt --all -- --check" ;; + rust:run) echo "cargo run --release" ;; + + idris2:*) + ipkg=$(first '*.ipkg' 1); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg'); [ -z "$ipkg" ] && ipkg=$(first '*.ipkg' 3); ipkg=${ipkg#./} + case "$v" in + deps) have pack && echo "pack install-deps $ipkg" ;; + build) have pack && echo "pack build $ipkg" || echo "idris2 --build $ipkg" ;; + test) local t; t=$(find . -maxdepth 3 -name 'test*.ipkg' -print -quit 2>/dev/null) + if [ -n "$t" ]; then have pack && echo "pack test ${t#./}" || echo "idris2 --build ${t#./}" + else have pack && echo "pack typecheck $ipkg" || echo "idris2 --typecheck $ipkg"; fi ;; + run) grep -qs '^[[:space:]]*executable' "$ipkg" && { have pack && echo "pack run $ipkg" || echo "idris2 --build $ipkg && ./build/exec/*"; } ;; + esac ;; + + julia:deps) echo "julia --project=. -e 'using Pkg; Pkg.instantiate()'" ;; + julia:build) echo "julia --project=. -e 'using Pkg; Pkg.precompile()'" ;; + julia:test) echo "julia --project=. -e 'using Pkg; Pkg.test()'" ;; + julia:bench) [ -f benchmark/benchmarks.jl ] && echo "julia --project=benchmark -e 'using Pkg; Pkg.develop(path=\".\"); Pkg.instantiate(); include(\"benchmark/benchmarks.jl\")'" ;; + + zig:*) + # build.zig may sit in ffi/zig/; run there, not at the root. + local zb zd; zb=$(first build.zig 1); [ -z "$zb" ] && zb=$(first build.zig); [ -z "$zb" ] && zb=$(first build.zig 3); [ -z "$zb" ] && zb=$(first build.zig 4) + zd=$(dirname "${zb#./}"); local cdz=""; [ "$zd" != . ] && cdz="cd '$zd' && " + case "$v" in + build) echo "${cdz}zig build" ;; + test) grep -qs '"test"' "$zb" && echo "${cdz}zig build test" ;; + bench) grep -qs '"bench"' "$zb" && echo "${cdz}zig build bench -Doptimize=ReleaseFast" ;; + fmt) echo "zig fmt $zd" ;; + fmt-check) echo "zig fmt --check $zd" ;; + lint) echo "zig fmt --check $zd" ;; + run) grep -qs '"run"' "$zb" && echo "${cdz}zig build run" ;; + esac ;; + + elixir:deps) echo "mix local.hex --force --if-missing && mix local.rebar --force --if-missing && mix deps.get" ;; + elixir:build) echo "mix compile --warnings-as-errors" ;; + elixir:test) echo "mix test" ;; + elixir:bench) ls bench/*.exs >/dev/null 2>&1 && echo "for f in bench/*.exs; do mix run \"\$f\"; done" ;; + elixir:lint) grep -qs ':credo' mix.exs && echo "mix credo --strict" || echo "mix compile --warnings-as-errors" ;; + elixir:fmt) echo "mix format" ;; + elixir:fmt-check) echo "mix format --check-formatted" ;; + elixir:run) echo "mix run --no-halt" ;; + + gleam:deps) echo "gleam deps download" ;; + gleam:build) echo "gleam build" ;; + gleam:test) echo "gleam test" ;; + gleam:fmt) echo "gleam format" ;; + gleam:fmt-check) echo "gleam format --check" ;; + gleam:lint) echo "gleam format --check" ;; + gleam:run) echo "gleam run" ;; + + ocaml:deps) echo "opam install . --deps-only --with-test -y" ;; + ocaml:build) echo "opam exec -- dune build" ;; + ocaml:test) echo "opam exec -- dune test" ;; + ocaml:fmt) echo "opam exec -- dune fmt" ;; + ocaml:fmt-check) echo "opam exec -- dune build @fmt" ;; + + haskell:deps) echo "cabal update && cabal build all --only-dependencies" ;; + haskell:build) echo "cabal build all" ;; + haskell:test) echo "cabal test all" ;; + haskell:bench) grep -qs '^benchmark' ./*.cabal && echo "cabal bench all" ;; + haskell:run) echo "cabal run" ;; + + bun:deps) [ -f bun.lock ] || [ -f bun.lockb ] && echo "bun install --frozen-lockfile" || echo "bun install" ;; + bun:build) grep -qs '"build"[[:space:]]*:' package.json && echo "bun run build" ;; + # `bun test` exits 0 when it finds no test files, so it runs only when some exist. + bun:test) if grep -qs '"test"[[:space:]]*:' package.json; then echo "bun run test" + elif git ls-files 2>/dev/null | grep -qE '(\.|_)(test|spec)\.(js|mjs|cjs|jsx)$'; then echo "bun test"; fi ;; + bun:bench) grep -qs '"bench"[[:space:]]*:' package.json && echo "bun run bench" ;; + bun:lint) grep -qs '"lint"[[:space:]]*:' package.json && echo "bun run lint" ;; + bun:fmt) grep -qs '"fmt"[[:space:]]*:' package.json && echo "bun run fmt" ;; + bun:fmt-check) grep -qs '"fmt-check"[[:space:]]*:' package.json && echo "bun run fmt-check" ;; + bun:run) grep -qs '"start"[[:space:]]*:' package.json && echo "bun run start" ;; + + docs:test) have asciidoctor && echo "for f in \$(git ls-files '*.adoc'); do asciidoctor -o /dev/null --failure-level=WARN \"\$f\" || exit 1; done" ;; + docs:lint) have lychee && echo "lychee --offline --no-progress \$(git ls-files '*.adoc' '*.md')" ;; + esac +} + +# Run a contract verb across every detected language. N/A is reported, not +# faked green: a verb with no command for any language exits 0 but SAYS so. +lang_run() { # $1 verb + local verb=$1 ran=0 rc=0 l cmd override + override=$(deed "$verb" "") + if [ -n "$override" ]; then + info "$verb (from provisioning_praxis.deed): $override" + bash -c "$override"; return $? + fi + for l in "${LANGS[@]}"; do + cmd=$(lang_cmd "$l" "$verb") + [ -z "$cmd" ] && continue + ran=1 + info "$verb [$l]: $cmd" + bash -c "$cmd" || { rc=$?; printf ' %sFAIL%s %s [%s] exited %s\n' "$R" "$Z" "$verb" "$l" "$rc" >&2; } + done + [ $ran -eq 0 ] && info "$verb: N/A for ${LANGS[*]} (archetype: $ARCHETYPE) — nothing to run, and nothing was faked" + return $rc +} + +# --------------------------------------------------------------------------- +# A repo keeps its own checks as root recipes named doctor-local / setup-local / +# heal-local (provision-set renames a pre-existing custom doctor/setup/heal to +# these); the canon verbs run them, so `just doctor`, `./launcher.sh --doctor` and +# CI all see the same result. +has_recipe() { have just && just --summary 2>/dev/null | tr " " "\n" | grep -qx "$1"; } + +# Return success when version $1 sorts at or above version $2 with sort -V. +version_ge() { [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]; } + +# Tools that must never appear in a repo's toolchain (estate language policy). +BANNED_TOOLS='python|deno|denojs|node|nodejs|npm|yarn|pnpm|typescript|rescript|make|black|ruff|pip|poetry|nix|go|golang|java|kotlin' +BANNED_BACKENDS='npm|pipx|pip|go' + +# --------------------------------------------------------------------------- +# Shared predicates. doctor and provision-check.sh both call THESE (the check via +# `provision-lib.sh guix-stub|mise-lock-gaps|mise-banned`): a gate with its own +# copy of a test passes what doctor warns about. + +# The tools every repository mise config declares, quotes stripped ("cargo:foo" +# stays cargo:foo): the [tools] keys of mise.toml and .mise.toml and the first +# word of each .tool-versions line. mise merges all three, so a predicate that +# read mise.toml alone would pass a banned tool pinned in the others. +mise_toml_tools() { + local f + for f in .tool-versions mise.toml .mise.toml; do + [ -f "$f" ] || continue + if [ "$f" = .tool-versions ]; then + awk '!/^[ \t]*(#|$)/{print $1}' "$f" + else + awk '/^\[tools\]/{t=1;next} /^\[/{t=0} t && /=/{sub(/[ \t]*=.*/,""); gsub(/["\x27 ]/,""); print}' "$f" + fi + done | awk '!seen[$0]++' +} +# True when a bare registry name resolves only to banned backends ("prettier" +# is only npm:prettier). Needs no network: `mise registry` reads the registry +# baked into mise. False when mise is absent or does not know the name, so an +# unknown tool is left to mise-lock to report, never called banned on a guess. +only_banned_backends() { + local b n=0 + command -v mise >/dev/null 2>&1 || return 1 + for b in $(mise registry "$1" 2>/dev/null); do + n=$((n + 1)) + [[ "$b" =~ ^($BANNED_BACKENDS): ]] || return 1 + done + [ "$n" -gt 0 ] +} +# Banned tools named in the mise configs. A backend prefix does not hide one +# ("aqua:denoland/deno" is deno), an npm:/pipx:/pip:/go: backend installs +# through a banned runtime whatever the package is ("npm:prettier"), and so does +# a bare name with no other backend ("prettier"). +mise_banned() { + local t base hits="" + for t in $(mise_toml_tools); do + base=${t##*:}; base=${base%%@*}; base=${base##*/} + if [[ "$base" =~ ^($BANNED_TOOLS)$ ]] || [[ "$t" =~ ^($BANNED_BACKENDS): ]]; then + hits="$hits$t " + elif [[ "$t" != *:* ]] && only_banned_backends "$t"; then + hits="$hits$t " + fi + done + printf '%s' "${hits% }" +} +# Why mise.lock does not pin mise.toml, or nothing when it does. Presence is not +# enough: a zero-byte lock pins nothing. Every [tools] key needs its [[tools.]] +# entry, and the lock must carry checksums. +mise_lock_gaps() { + [ -f mise.toml ] || return 0 + [ -f mise.lock ] || { echo "mise.lock missing"; return; } + [ -s mise.lock ] || { echo "mise.lock is empty"; return; } + local t miss="" + # A tool is pinned when its [[tools.X]] block carries a concrete version line. + for t in $(mise_toml_tools); do + awk -v a="[[tools.$t]]" -v b="[[tools.\"$t\"]]" ' + $0 == a || $0 == b { inb = 1; next } + /^\[/ { inb = 0 } + inb && /^version = "[^"]+"/ { ok = 1 } + END { exit !ok }' mise.lock || miss="$miss$t " + done + [ -n "$miss" ] && { echo "mise.lock does not pin: ${miss% }"; return; } + # Checksums are per artefact: every [tools.X."platforms.P"] table needs its own + # sha256, so one checksummed tool cannot vouch for another. A tool with no + # platform tables has no artefact to checksum (core:rust installs through + # rustup, cargo: builds from source), which is what `mise lock` writes for it. + miss=$(awk ' + # Print the open platform table as tool/platform when it carried no sha256. + function close_table() { if (p != "" && !c) printf "%s ", p; p = "" } + /^\[tools\..*platforms\./ { close_table(); p = $0; c = 0 + gsub(/^\[tools\.|\]$|"/, "", p); sub(/\.platforms\./, "/", p); next } + /^\[/ { close_table() } + /^checksum = "sha256:[0-9a-f]+"/ { c = 1 } + END { close_table() }' mise.lock) + [ -z "$miss" ] || echo "mise.lock has no sha256 for: ${miss% }" +} +# Why a Guix file is a stub, or nothing when it is real. The test is positive: a +# guix.scm must define every field a package needs, not merely avoid known stub +# shapes; `(package (name "x") (source (local-file ".")))` is a stub. +guix_stub_reason() { + local f=$1 k miss="" + [ -f "$f" ] || { echo "missing"; return; } + grep -qE '\{\{|__[A-Z][A-Z_]*__' "$f" && { echo "unfilled template slots"; return; } + grep -qE '\(inputs \(list\)\)|\(source #f\)' "$f" && { echo "empty inputs or no source"; return; } + case "${f##*/}" in + manifest.scm) grep -q 'specifications->manifest' "$f" || echo "lists no specifications"; return ;; + channels.scm) grep -qE '\(commit "[0-9a-f]{40}"\)' "$f" || echo "pins no commit"; return ;; + esac + for k in name version source build-system home-page synopsis description license; do + grep -qE "\\(${k}[[:space:]]" "$f" || miss="$miss$k " + done + [ -n "$miss" ] && { echo "no package field: ${miss% }"; return; } + if grep -q 'crates\.scm' "$f"; then + grep -qs 'define %crate-inputs' build/guix/crates.scm || echo "build/guix/crates.scm is missing or defines no %crate-inputs" + fi +} + +# --------------------------------------------------------------------------- +# Facts the generator writes into docs/SETUP.adoc and the AI guide. They live here, +# beside lang_tools and lang_remedy, so no second per-language table exists. +lang_title() { case "$1" in + rust) echo Rust ;; idris2) echo Idris2 ;; julia) echo Julia ;; zig) echo Zig ;; elixir) echo Elixir ;; + gleam) echo Gleam ;; ocaml) echo OCaml ;; haskell) echo Haskell ;; bun) echo Bun ;; docs) echo Docs ;; esac; } +# What a language's route needs from the OS: "fedora|debian|macos command|why", or nothing. +lang_sysdeps() { case "$1" in + rust) echo "gcc pkgconf-pkg-config|build-essential pkg-config|xcode-select --install|Rust links through the system C toolchain, and pkg-config finds C libraries" ;; + idris2) echo "chez-scheme gmp-devel|chezscheme libgmp-dev|brew install chezscheme gmp|pack builds Idris2 on top of Chez Scheme and GMP" ;; + elixir|gleam) echo "gcc gcc-c++ make autoconf ncurses-devel openssl-devel|build-essential autoconf m4 libncurses-dev libssl-dev|brew install autoconf openssl@3|mise builds Erlang/OTP from source (allow ~10 min); the make here is the system tool that build uses, not a Makefile in this repository" ;; + ocaml) echo "gcc make patch unzip bubblewrap|build-essential patch unzip bubblewrap|xcode-select --install|opam compiles OCaml and sandboxes its builds with bubblewrap" ;; + haskell) echo "gcc gcc-c++ gmp-devel make ncurses-devel xz perl|build-essential curl libffi-dev libgmp-dev libncurses-dev|xcode-select --install|ghcup installs GHC, which links through the C toolchain and GMP" ;; +esac; } +# Print tools needed outside Guix for detected languages, or none. +guix_gaps() { + local l g gaps="" + for l in "${LANGS[@]}"; do g=$(lang_guix_gap "$l"); [ -n "$g" ] && gaps="$gaps${gaps:+, }$g"; done + printf '%s\n' "${gaps:-none}" +} +# Print AsciiDoc table rows describing language and recipe tools and installation routes. +tool_table() { + local l t base + for l in "${LANGS[@]}"; do + if [ "$l" = docs ]; then echo "|docs |\`lychee\` |mise use lychee@latest"; continue; fi + t=$(lang_tools "$l"); echo "|$l |\`${t// /\`, \`}\` |$(lang_remedy "$l")" + done + base="$MISE_BASE $(recipe_tools | xargs)"; base=$(echo "$base" | xargs) + echo "|(recipes) |\`${base// /\`, \`}\` |mise install, or your OS package manager (e.g. \`dnf install just ShellCheck\`)" +} +# shellcheck disable=SC2016 # the backticks are AsciiDoc literals, not command substitution +system_deps() { # $1 adoc|ai + local l d seen="" fed deb mac why any=0 + [ "$1" = adoc ] && printf '=== System packages\n' + for l in "${LANGS[@]}"; do + d=$(lang_sysdeps "$l"); [ -z "$d" ] && continue + case "$seen" in *"|$d|"*) continue ;; esac; seen="$seen|$d|"; any=1 + IFS='|' read -r fed deb mac why <<<"$d" + if [ "$1" = adoc ]; then + printf '\n%s: %s.\n\n* Fedora: `sudo dnf install %s`\n* Debian/Ubuntu: `sudo apt install %s`\n* macOS: `%s`\n' \ + "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" + else + printf '* %s needs OS packages (%s): Fedora `sudo dnf install %s` · Debian/Ubuntu `sudo apt install %s` · macOS `%s`.\n' \ + "$(lang_title "$l")" "$why" "$fed" "$deb" "$mac" + fi + done + if [ "$1" = adoc ]; then + if [ $any -eq 1 ]; then printf '\nThe Guix development shell (`%s`) provides these itself, so inside `just dev-shell` none of them is needed.\n' "$(gpath manifest.scm)" + else printf '\nNone beyond git and a shell: every tool this repository needs comes from mise, or from the Guix shell.\n'; fi + elif [ $any -eq 0 ]; then printf '* No other OS packages: every tool comes from mise.\n'; fi +} + +# Diagnose the provisioning set and toolchain, then the repository's own +# doctor-local checks. The PASS/WARN/FAIL tally is the last line of stdout; +# returns non-zero when anything FAILed. +cmd_doctor() { + printf '%s doctor — %s (%s; languages: %s)\n' "$REPO_NAME" "$REPO_SLUG" "$ARCHETYPE" "${LANGS[*]}" + + hdr "Core toolchain" + have git && pass "git $($T git --version 2>/dev/null | awk '{print $3}')" || fail "PV-E01 git not found — install git from your OS package manager" + if have just; then + local jv; jv=$($T just --version 2>/dev/null | awk '{print $2}') + version_ge "$jv" "$MIN_JUST" && pass "just $jv (>= $MIN_JUST)" || fail "PV-E02 just $jv is older than $MIN_JUST — run: mise use just@latest" + else fail "PV-E02 just not found — run: mise use -g just@latest (or see docs/SETUP.adoc)"; fi + if have mise; then + pass "mise $($T mise --version 2>/dev/null | awk '{print $1}')" + if [ -f mise.toml ] || [ -f .mise.toml ]; then + # Only THIS repo's declarations: `mise ls` also lists the user's global config. + local here=${ROOT/#$HOME/\~} missing + missing=$($T mise ls --current --missing 2>/dev/null | grep -F -e "$here/mise.toml" -e "$here/.mise.toml" -e "$ROOT/mise.toml" | awk '{print $1"@"$2}' | tr '\n' ' ') + [ -z "${missing// /}" ] && pass "every mise tool is installed" || fail "PV-E03 mise tools not installed: $missing— run: just setup" + fi + else warn "PV-W01 mise not found — tools must then come from Guix or your OS; install: $MISE_INSTALL_HINT"; fi + if have guix; then pass "guix $($T guix --version 2>/dev/null | head -1 | awk '{print $NF}') (optional reproducible path)" + else info "guix not installed — optional; mise is the default path (docs/SETUP.adoc §Guix)"; fi + + hdr "Language toolchains" + local l t + for l in "${LANGS[@]}"; do + [ "$l" = docs ] && { info "no build language detected — docs/theory archetype"; continue; } + for t in $(lang_tools "$l"); do + if have "$t"; then pass "$l: $t ($(command -v "$t"))" + else fail "PV-E10 $l: '$t' not found — run: just setup (manual: $(lang_remedy "$l"))"; fi + done + done + local extra; extra=$(deed system-deps "") + for t in $extra; do have "$t" && pass "system dep: $t" || fail "PV-E11 system dependency '$t' not found (declared in $DEED) — see docs/SETUP.adoc §Prerequisites"; done + + hdr "Repository provisioning files" + [ -f mise.toml ] && pass "mise.toml" || fail "PV-E20 mise.toml missing — the toolchain is undeclared" + local lg; lg=$(mise_lock_gaps) + [ -z "$lg" ] && pass "mise.lock pins every mise.toml tool (latest → concrete, checksummed)" || warn "PV-W20 $lg — run: just toolchain-refresh" + [ -f .mise.toml ] && [ -f mise.toml ] && warn "PV-W21 both mise.toml and .mise.toml — mise merges them; keep only mise.toml" + [ -f .tool-versions ] && warn "PV-W22 .tool-versions present — a second toolchain source; fold it into mise.toml" + if [ -f mise.toml ] || [ -f .mise.toml ] || [ -f .tool-versions ]; then + local bad; bad=$(mise_banned) + [ -z "$bad" ] && pass "the mise configs pin no banned tool" || warn "PV-W23 a mise config pins banned tool(s): $bad (language policy: bun, no python/deno/node/npm/make)" + fi + # hypatia guix_not_stub reads guix.scm AND build/guix.scm; an unfilled __PLACEHOLDER__ is a stub too. + local g r gstub="" + for g in guix.scm build/guix.scm "$(gpath manifest.scm)" "$(gpath channels.scm)"; do + [ -f "$g" ] || continue + r=$(guix_stub_reason "$g"); [ -n "$r" ] && gstub="$gstub$g ($r) " + done + local gs gm; gs=$(gpath guix.scm); gm=$(gpath manifest.scm) + [ -f guix.scm ] && [ -f build/guix.scm ] && warn "PV-W35 both guix.scm and build/guix.scm exist — two Guix sources; keep one (build/ is used)" + if [ -f "$gs" ]; then + if [ -n "$gstub" ]; then warn "PV-W24 template stub in: $gstub— the Guix path does not build this repo (just heal cannot fix this; provision-set realign does)" + else pass "$gs (non-stub)"; fi + else warn "PV-W25 guix.scm missing — no reproducible Guix path"; fi + [ -f "$gm" ] && pass "$gm (guix shell -m $gm)" || warn "PV-W26 $gm missing — 'just dev-shell' falls back to mise" + [ -x launcher.sh ] && pass "launcher.sh (executable)" || { [ -f launcher.sh ] && fail "PV-E27 launcher.sh not executable — run: just heal" || warn "PV-W27 launcher.sh missing"; } + local d + for d in docs/SETUP.adoc docs/AI_INSTALLATION_GUIDE.adoc "$(wpath user)" "$(wpath dev)" "$(wpath maintainer)"; do + if [ ! -f "$d" ]; then warn "PV-W28 $d missing" + elif grep -qE "__[A-Z][A-Z_]*__" "$d"; then warn "PV-W29 $d still has unfilled template slots (__SPEC_…__) — replace each with the facts for this repo" + else pass "$d"; fi + done + + hdr "Estate policy drift" + local f any=0 + for f in deno.json deno.jsonc deno.lock import_map.json; do + [ -f "$f" ] && { warn "PV-W30 $f — Deno leftover; the estate runtime is bun (migration issue tracks it)"; any=1; } + done + for f in flake.nix flake.lock shell.nix default.nix; do [ -f "$f" ] && { warn "PV-W31 $f — Nix is not an estate toolchain; Guix is"; any=1; }; done + for f in Makefile GNUmakefile makefile; do [ -f "$f" ] && { warn "PV-W32 $f — Justfile is the only task runner"; any=1; }; done + for f in requirements.txt pyproject.toml setup.py Pipfile; do [ -f "$f" ] && { warn "PV-W33 $f — Python is not an estate language"; any=1; }; done + for f in package-lock.json yarn.lock pnpm-lock.yaml tsconfig.json; do [ -f "$f" ] && { warn "PV-W34 $f — npm/yarn/pnpm/TypeScript leftover; bun only"; any=1; }; done + [ $any -eq 0 ] && pass "no deno / nix / make / python / npm leftovers" + + local hook=build/just/doctor-local.sh + if [ -f "$hook" ]; then + hdr "Repo-specific checks ($hook)" + # Sourced so the hook can call pass/warn/fail, but in a subshell so an `exit` + # or `set -e` in it cannot end the doctor before the summary. The EXIT trap + # hands the hook's tally back; an early exit is itself a FAIL. + local tally qtally hp w f rc + tally=$(mktemp) + printf -v qtally '%q' "$tally" + # shellcheck disable=SC2030,SC2031 # the subshell's counts return via $tally + ( PASS=0 WARN=0 FAIL=0 hook_done=0 + # The path is baked in now: when `set -e` trips, bash unwinds this + # function's locals before the EXIT trap runs, so $tally is gone by then. + # shellcheck disable=SC2064 + trap "printf '%d %d %d %d\n' \"\$PASS\" \"\$WARN\" \"\$FAIL\" \"\$hook_done\" > $qtally" EXIT + # shellcheck source=/dev/null + . "$hook" + hook_done=1 ) + rc=$? + read -r hp w f hook_done < "$tally" || { hp=0 w=0 f=0 hook_done=0; } + rm -f "$tally" + # shellcheck disable=SC2031 + PASS=$((PASS+hp)) WARN=$((WARN+w)) FAIL=$((FAIL+f)) + [ "$hook_done" = 1 ] || fail "PV-E51 $hook exited (status $rc) before it finished; its later checks did not run" + fi + if has_recipe doctor-local; then + hdr "Repo-specific checks (just doctor-local)" + just doctor-local && pass "doctor-local" || fail "PV-E50 the repo-specific doctor-local recipe failed (output above)" + fi + + printf '\n%s: %s%d PASS%s, %s%d WARN%s, %s%d FAIL%s\n' "$REPO_NAME" "$G" "$PASS" "$Z" "$Y" "$WARN" "$Z" "$R" "$FAIL" "$Z" + if [ "$FAIL" -gt 0 ]; then + # stderr, so the tally above stays the last line of stdout on every outcome. + echo "Next: 'just heal' fixes what is safe to fix automatically; each FAIL code is explained in docs/SETUP.adoc §Troubleshooting." >&2 + return 1 + fi + return 0 +} + +# Install tools and dependencies, run local setup hooks, and fail if setup or doctor fails. +cmd_setup() { + printf '%s setup — installing everything this repository needs\n' "$REPO_NAME" + local rc=0 + if have mise; then + hdr "mise toolchain" + $T mise trust -q . 2>/dev/null || true + mise install || rc=1 + else + warn "mise not found. Install it ($MISE_INSTALL_HINT), or enter the Guix shell with 'just dev-shell', then re-run: just setup" + fi + local l + for l in "${LANGS[@]}"; do + case "$l" in + idris2) have pack || warn "pack not found — Idris2 comes from pack, not mise: $(lang_remedy idris2)" ;; + haskell) have ghcup || have ghc || warn "ghc not found — $(lang_remedy haskell)" ;; + ocaml) have opam && { opam switch show >/dev/null 2>&1 || opam init -y --bare; } ;; + esac + done + hdr "Project dependencies" + # Put this repo's mise tools on PATH for the dependency step. + if have mise; then PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH"; export PATH; fi + lang_run deps || rc=1 + local hook=build/just/setup-local.sh + [ -f "$hook" ] && { hdr "Repo-specific setup ($hook)"; bash "$hook" || rc=1; } + has_recipe setup-local && { hdr "Repo-specific setup (just setup-local)"; just setup-local || rc=1; } + [ -f launcher.sh ] && chmod +x launcher.sh + hdr "Verification" + cmd_doctor || rc=1 + [ $rc -eq 0 ] && printf '\n%sReady.%s Try: just --list\n' "$G" "$Z" || printf '\n%sSetup incomplete%s — read the FAIL lines above; docs/SETUP.adoc has the manual route.\n' "$R" "$Z" + return $rc +} + +# Attempt tool and permission repairs plus local hooks, then return the doctor result. +cmd_heal() { + printf '%s heal — applying safe, reversible fixes, then re-checking\n' "$REPO_NAME" + hdr "Fixes" + [ -f launcher.sh ] && [ ! -x launcher.sh ] && chmod +x launcher.sh && info "made launcher.sh executable" + for f in build/just/*.sh scripts/*.sh; do [ -f "$f" ] && [ ! -x "$f" ] && chmod +x "$f" && info "made $f executable"; done + if have mise; then + $T mise trust -q . 2>/dev/null && info "mise config trusted" + mise install && info "mise tools installed" + [ -f mise.lock ] || { $T mise lock 2>/dev/null && info "mise.lock generated"; } + fi + if printf '%s\n' "${LANGS[@]}" | grep -qx elixir && have mix; then mix deps.get >/dev/null 2>&1 && info "mix deps fetched"; fi + if printf '%s\n' "${LANGS[@]}" | grep -qx bun && have bun; then bun install >/dev/null 2>&1 && info "bun deps installed"; fi + if printf '%s\n' "${LANGS[@]}" | grep -qx julia && have julia; then julia --project=. -e 'using Pkg; Pkg.instantiate()' >/dev/null 2>&1 && info "julia deps instantiated"; fi + local hook=build/just/heal-local.sh + [ -f "$hook" ] && { info "running $hook"; bash "$hook" || true; } + has_recipe heal-local && { info "running just heal-local"; just heal-local || true; } + echo "Not touched (never automatic): source files, git history, Deno/Nix/Make leftovers — those are migrations, tracked as issues." + hdr "Re-check" + cmd_doctor +} + +# Replace this process with a Guix or mise shell; fail if neither route is available. +cmd_dev_shell() { + local gm; gm=$(gpath manifest.scm) + if have guix && [ -f "$gm" ] && ! grep -q '{{' "$gm"; then + info "entering: guix shell -m $gm (exit to leave)" + exec guix shell -m "$gm" + elif have mise; then + info "guix/manifest.scm unavailable — entering the mise environment instead (exit to leave)" + exec mise exec -- "${SHELL:-bash}" + else + fail "PV-E40 neither guix nor mise is installed — see docs/SETUP.adoc"; return 1 + fi +} + +# Print the 40-hex commit of the `guix` channel in a channels list read from +# stdin (`guix describe --format=channels`), or nothing when there is none. +guix_channel_commit() { + awk '/\(name .guix\)/ { g = 1 } + g && match($0, /\(commit "[0-9a-f]{40}"\)/) { print substr($0, RSTART + 9, 40); exit }' +} + +# Re-pin only the `guix` channel's commit in channels file $1 to $2, keeping +# every other line (comments, other channels, introduction) as it is. Returns +# non-zero, leaving the file untouched, when it has no guix channel commit. +repin_guix_channel() { + local ch=$1 pin=$2 tmp + tmp=$(mktemp) || return 1 + if awk -v pin="$pin" ' + /\(name .guix\)/ { g = 1 } + g && !done && sub(/\(commit "[0-9a-f]{40}"\)/, "(commit \"" pin "\")") { done = 1 } + { print } + END { exit !done }' "$ch" > "$tmp"; then + cat "$tmp" > "$ch"; rm -f "$tmp" + else + rm -f "$tmp"; return 1 + fi +} + +# Weekly toolchain refresh: bump mise pins and the lock, re-pin the Guix channel, +# regenerate build/guix/crates.scm when guix.scm loads it, then show the diff +# for a signed commit. +cmd_toolchain_refresh() { + hdr "mise: bump 'latest' resolutions and re-lock" + have mise || { fail "mise not found"; return 1; } + mise up --bump || return 1 + $T mise lock 2>/dev/null || mise lock || return 1 + local ch; ch=$(gpath channels.scm) + if [ -f "$ch" ]; then + hdr "guix: channel pin" + if have guix; then + local pin + pin=$(guix describe --format=channels 2>/dev/null | guix_channel_commit) + if [ -z "$pin" ]; then + warn "could not read the current guix channel commit — $ch left unchanged" + elif repin_guix_channel "$ch" "$pin"; then + info "$ch: guix channel re-pinned to $pin" + else + warn "$ch has no (name 'guix) channel with a commit — left unchanged" + fi + else info "guix not installed — $ch left as-is (CI re-pins it)"; fi + fi + local cr="" + if [ -f Cargo.lock ] && grep -qs 'crates\.scm' "$(gpath guix.scm)"; then + hdr "guix: crate inputs from Cargo.lock" + cr=build/guix/crates.scm + if have "${GUIX%% *}"; then cmd_crates_scm || return 1 + else info "guix not installed — $cr left as-is (CI regenerates it)"; fi + fi + git --no-pager diff --stat -- mise.toml mise.lock "$ch" $cr 2>/dev/null + echo "Commit the diff above (signed) as: chore(toolchain): weekly refresh" +} + +# The guix command (default: guix), e.g. a wrapper that runs it in a container +# with this directory mounted. +GUIX="${GUIX:-guix}" + +# Write build/guix/crates.scm: every registry crate in Cargo.lock as a Guix +# origin, and %crate-inputs listing them for guix.scm. Written whole or not at +# all. The importer's output is accepted only when it defines exactly one crate +# source per registry package in Cargo.lock: run through a container, guix's exit +# status is lost, so the count is the check. +cmd_crates_scm() { + local dst=build/guix/crates.scm want got tmp spdx + [ -f Cargo.lock ] || { info "no Cargo.lock — no crate inputs"; return 0; } + want=$(grep -c '^source = "registry+' Cargo.lock) + tmp=$(mktemp) || return 1 + if [ "$want" -gt 0 ]; then + # GUIX is split on purpose: it may be a command with arguments. + # shellcheck disable=SC2086 + timeout 1800 $GUIX import crate --lockfile=Cargo.lock "$REPO_NAME" >"$tmp" 2>"$tmp.err" + got=$(grep -c '^(define rust-' "$tmp") + if [ "$got" != "$want" ]; then + fail "PV-E41 guix import crate defined $got of the $want registry crates in Cargo.lock; $dst left as-is: $(grep -m1 -i 'error' "$tmp.err" || grep -v '^+' "$tmp.err" | tail -1)" + rm -f "$tmp" "$tmp.err"; return 1 + fi + fi + # The licence line is guix.scm's own, so the file matches its repository. + spdx=$(grep -m1 'SPDX-License-Identifier' "$(gpath guix.scm)" 2>/dev/null) + mkdir -p build/guix + { + [ -n "$spdx" ] && printf '%s\n' "$spdx" + # shellcheck disable=SC2016 # the backticks are literal text + printf ';; Generated from Cargo.lock by `just toolchain-refresh` (guix import crate\n' + printf ';; --lockfile); never hand-edit it. guix.scm loads it for %%crate-inputs.\n\n' + grep -v '^guix (GNU Guix)' "$tmp" + printf '\n(define %%crate-inputs\n (list' + grep -o '^(define rust-[^ ]*' "$tmp" | awk '{printf "\n %s", $2}' + printf '))\n' + } >"$dst.new" && mv "$dst.new" "$dst" + rm -f "$tmp" "$tmp.err" + info "$dst: $want crate source(s)" +} + +# The sentence people are told to say lives in one place a reader sees: the first +# listing block of the README's [[ai-install]] section. The deed's ai-say-it and +# the generic line are fallbacks for a README without that section. +just_say_it() { + local line="" r + for r in README.adoc README.md; do + [ -f "$r" ] || continue + line=$(awk '/^\[\[ai-install\]\]/{on=1} on && /^----$/{if (inb) exit; inb=1; next} inb && NF{print; exit}' "$r") + [ -n "$line" ] && break + done + [ -z "$line" ] && line=$(deed ai-say-it "") + [ -z "$line" ] && line="Set up $REPO_NAME from https://github.com/$REPO_SLUG — follow docs/AI_INSTALLATION_GUIDE.adoc in that repo." + printf '%s' "$line" +} +# Copy stdin through an available clipboard tool within three seconds; fail silently otherwise. +clip() { # best-effort; silent when no clipboard exists (CI, SSH) + # wl-copy and xclip fork a daemon that inherits stdout; left attached, it + # holds any pipeline open forever (observed 2026-09-30). Detach and bound it. + local c=() + if have clip.exe; then c=(clip.exe) # WSL: the Windows clipboard + elif have pbcopy; then c=(pbcopy) + elif [ -n "${WAYLAND_DISPLAY:-}" ] && have wl-copy; then c=(wl-copy) + elif [ -n "${DISPLAY:-}" ] && have xclip; then c=(xclip -selection clipboard) + else cat >/dev/null; return 1; fi + timeout 3 "${c[@]}" >/dev/null 2>&1 +} +# Print AI setup guidance and attempt to copy its setup line to the clipboard. +cmd_ai_setup() { + cat <" >&2; return 2 ;; esac + for f in "$(wpath "$who")" "llm-warmup-$who.adoc" "docs/onboarding/llm-warmup-$who.adoc" "docs/llm-warmup-$who.adoc"; do + if [ -f "$f" ]; then + cat "$f"; clip < "$f" && echo "--- (copied $f to your clipboard — paste it as your first message to any AI)" >&2 || true + return 0 + fi + done + fail "llm-warmup-$who.adoc not found"; return 1 +} + +# Run and time the test and bench recipes, save their output, and fail if either fails. +cmd_eval() { + local logf rc=0 s e v r o st + logf=".eval/$(date -u +%Y%m%dT%H%M%SZ).txt" + mkdir -p .eval + { echo "# $REPO_NAME evaluation — $(date -u +%FT%TZ) — $(git rev-parse --short HEAD 2>/dev/null || echo no-git)" + echo "# languages: ${LANGS[*]} archetype: $ARCHETYPE"; } > "$logf" + for v in test bench; do + # The root recipe if the repo defines one (its own override), else the module default. + if just --summary 2>/dev/null | tr " " "\n" | grep -qx "$v"; then r=$v; else r="provision::$v"; fi + s=$(date +%s); o=$(mktemp) + if just "$r" >"$o" 2>&1; then st=PASS; else st=FAIL; rc=1; fi + e=$(( $(date +%s) - s )); cat "$o" >>"$logf" + # A skip is not a pass: lang_run exits 0 on N/A (users see success), eval says N/A. + [ "$st" = PASS ] && grep -q "nothing was faked" "$o" && st=N/A + rm -f "$o"; echo "$v: $st (${e}s)" | tee -a "$logf" + done + echo "full log: $logf" + return $rc +} + +# Print repository identity, declared configuration and detected toolchain file locations. +cmd_config_show() { + echo "repo: $REPO_SLUG" + echo "archetype: $ARCHETYPE" + echo "languages: ${LANGS[*]}" + echo "descriptor: $DEED $([ -f "$DEED" ] && echo '(present)' || echo '(absent — defaults in use)')" + echo "config: $(deed config "none declared")" + echo "run: $(deed run "$(for l in "${LANGS[@]}"; do lang_cmd "$l" run; done | head -1)")" + echo "ports: $(deed ports "none")" + echo "mise: $([ -f mise.toml ] && echo mise.toml) $([ -f mise.lock ] && echo mise.lock)" + echo "guix: $(for f in guix.scm manifest.scm channels.scm; do f=$(gpath "$f"); [ -f "$f" ] && printf "%s " "$f"; done)" + echo "lib: provision-lib $PROVISION_LIB_VERSION" +} + +# Print repository-specific OPSM installation instructions and any deed note. +cmd_opsm() { + cat <} + if have agrep; then git ls-files -z | xargs -0 agrep -n -1 -- "$p" 2>/dev/null + else git grep -n -i -- "$p"; fi +} + +case "${1:-}" in + guix-specs) guix_specs ;; + mise-tools) mise_tools ;; + langs) printf '%s\n' "${LANGS[@]}" ;; + doctor) cmd_doctor ;; + setup) cmd_setup ;; + heal) cmd_heal ;; + dev-shell) cmd_dev_shell ;; + toolchain-refresh) cmd_toolchain_refresh ;; + crates-scm) cmd_crates_scm ;; + ai-setup) cmd_ai_setup ;; + ai-warmup) shift; cmd_ai_warmup "${1:-user}" ;; + eval) cmd_eval ;; + config-show) cmd_config_show ;; + opsm) cmd_opsm ;; + search) shift; cmd_search "${1:-}" ;; + lang-run) shift; lang_run "${1:?verb}" ;; + version) echo "provision-lib $PROVISION_LIB_VERSION" ;; + guix-dir) guix_dir ;; + set-files) set_files ;; + guix-gaps) guix_gaps ;; + tool-table) tool_table ;; + system-deps) shift; system_deps "${1:-adoc}" ;; + # Predicates: print why, exit 1; print nothing, exit 0. + guix-stub) shift; r=$(guix_stub_reason "${1:?usage: guix-stub FILE}"); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + mise-lock-gaps) r=$(mise_lock_gaps); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + mise-banned) r=$(mise_banned); [ -z "$r" ] || { echo "$r"; exit 1; } ;; + *) sed -n '2,20p' "$0"; exit 2 ;; +esac diff --git a/standards/provisioning/templates/build/just/provision-modes.sh b/standards/provisioning/templates/build/just/provision-modes.sh new file mode 100755 index 0000000..50a9fa7 --- /dev/null +++ b/standards/provisioning/templates/build/just/provision-modes.sh @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# shellcheck shell=bash +# +# provision-modes.sh — the launcher-standard 0.6.0 provisioning mode family, +# as a SOURCEABLE block shared by every launcher in the estate. +# +# New launchers (library/tool/theory/docs): templates/launcher.sh.tmpl sources +# this and calls hp_launcher_main. +# Existing app launchers: add ONE line before their mode switch — +# +# . "$REPO_DIR/build/just/provision-modes.sh" && hp_provision_or_return "$@" +# +# A provisioning mode runs and the launcher exits with its status (a failed +# --setup exits non-zero); any other mode returns, so the app's own switch +# handles it. hp_provision_dispatch itself returns 99 for "not mine". +# +# Canon: hyperpolymath/standards launcher/launcher-standard_praxis.deed +# (archetypes, provisioning-modes) and +# 3-practice/provisioning/PROVISIONING-STANDARD.adoc + +HP_PROVISION_MODES_VERSION="0.6.0" + +# Print deed field $1, or default $2 when the field or deed is absent. +hp__deed() { # $1 key, $2 default — flat (key "value") read from provisioning_praxis.deed + local f="$REPO_DIR/.machine_readable/descriptiles/provisioning_praxis.deed" v="" + [ -f "$f" ] && v=$(grep -oE "[(:]$1[[:space:]]+\"[^\"]*\"" "$f" | head -1 | sed -E 's/^[^"]*"//; s/"$//') + printf '%s' "${v:-$2}" +} + +# Print the declared archetype, defaulting to library. +hp_archetype() { hp__deed archetype "library"; } +# Without a deed, the origin remote names the repo (a worktree or renamed clone +# has another directory name); the directory is the last resort. +hp_app_name() { + local u; u=$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true) + u=${u%.git}; u=${u##*/} + hp__deed name "${u:-$(basename "$REPO_DIR")}" +} + +# Map the host kernel name to linux, macos, windows or unknown. +hp_platform() { + case "$(uname -s)" in + Linux*) echo linux ;; + Darwin*) echo macos ;; + CYGWIN*|MINGW*|MSYS*|Windows_NT) echo windows ;; + *) echo unknown ;; + esac +} + +# Make sure `just` is runnable: PATH, then mise, then say exactly what to do. +hp_ensure_just() { + command -v just >/dev/null 2>&1 && return 0 + if command -v mise >/dev/null 2>&1; then + echo "just is not installed — installing it with mise (mise use -g just@latest)..." >&2 + mise use -g just@latest >&2 && PATH="$(mise bin-paths 2>/dev/null | paste -sd: -):$PATH" && command -v just >/dev/null 2>&1 && return 0 + mise exec just@latest -- true >/dev/null 2>&1 && { just() { mise exec just@latest -- just "$@"; }; return 0; } + fi + cat >&2 <<'EOF' +Neither `just` nor `mise` is installed. Install mise (it then installs everything else): + + brew install mise # macOS + sudo dnf copr enable jdxcode/mise && sudo dnf install mise # Fedora + winget install jdx.mise # Windows + # anything else (Debian/Ubuntu apt, …): https://mise.jdx.dev/installing-mise.html + # or download the installer, read it, then run it — never pipe it into a shell: + curl -fsSLo mise-install.sh https://mise.run && less mise-install.sh && sh mise-install.sh + +then re-run this command. Manual route without mise: docs/SETUP.adoc +EOF + return 1 +} + +# Ensure just is available and run its arguments from REPO_DIR, forwarding the status. +hp_just() { hp_ensure_just || return 1; (cd "$REPO_DIR" && just "$@"); } +# The provisioning modes call the engine directly, not a `just` recipe: a repo may +# define its own root `doctor`/`setup`/`heal`, and the launcher must still run the +# canon (which then runs that repo's *-local recipes). Only needs bash. +hp_lib() { (cd "$REPO_DIR" && bash build/just/provision-lib.sh "$@"); } + +# Returns the mode's exit code, or 99 when "$1" is not a provisioning mode. +# The one-line hook for existing launchers: exit with a provisioning mode's +# status, or return (0) so the caller's own mode switch runs. +hp_provision_or_return() { + hp_provision_dispatch "$@" + local rc=$? + [ "$rc" -eq 99 ] && return 0 + exit "$rc" +} + +# Run a recognised provisioning mode; return 99 when $1 belongs to another mode family. +hp_provision_dispatch() { + case "${1:-}" in + --setup) hp_lib setup ;; + --doctor) hp_lib doctor ;; + --heal) hp_lib heal ;; + --ai-setup) hp_lib ai-setup ;; + *) return 99 ;; + esac +} + +# Print the launcher name, version, commit and platform, using fallbacks for missing metadata. +hp_version_line() { + local sha ver + sha=$(git -C "$REPO_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown) + ver=$(hp__deed version "") + [ -z "$ver" ] && ver=$(git -C "$REPO_DIR" describe --tags --abbrev=0 2>/dev/null || echo 0.0.0) + printf '%s-launcher %s (%s) [%s-%s]\n' "$(hp_app_name)" "${ver#v}" "$sha" "$(hp_platform)" "$(uname -m)" +} + +# Print launcher usage and the runtime modes appropriate to the declared archetype. +hp_help() { + local name arch; name=$(hp_app_name); arch=$(hp_archetype) + cat < + +Provisioning (every repository): + --setup Install everything this repository needs, then run --doctor + --doctor Check the environment; PASS/WARN/FAIL with fix hints (exit 1 on FAIL) + --heal Apply safe fixes automatically, then re-run --doctor + --ai-setup Print the one line to give any AI assistant to set this up for you + +Meta: + --help This text + --version Machine-readable version line + +EOF + if [ "$arch" = app ]; then + echo "Runtime: --start --stop --status --auto (default) --integ --disinteg" + else + echo "Runtime modes (--start --stop --status --auto --integ --disinteg) do not apply" + echo "to a $arch repository; they are accepted and explain themselves." + fi + cat <&2 + return 1 + fi + echo "$(hp_app_name) is a $arch: there is nothing to ${mode#--}. Try --setup, --doctor or --help." + return 0 ;; + *) + echo "Unknown mode: $mode" >&2; hp_help >&2; return 2 ;; + esac +} diff --git a/standards/provisioning/templates/build/just/provision.just b/standards/provisioning/templates/build/just/provision.just new file mode 100644 index 0000000..9f1ac7c --- /dev/null +++ b/standards/provisioning/templates/build/just/provision.just @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# provision.just — the estate provisioning contract, as a just MODULE. +# +# Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +# Loaded from the root Justfile with: +# +# mod provision 'build/just/provision.just' +# +# A module (not `import`) so these recipes live under `provision::` and never +# collide with a repo's own `doctor`, `heal` or `test`. The root Justfile then +# exposes each contract name ONCE — either its own recipe, or a one-line +# delegation such as `doctor: provision::doctor` that `provision-set mint` +# adds only when the root has no recipe of that name. +# +# requires: just >= 1.42.0 (root recipes depend on module recipes; measured 2026-09-30: 1.41 rejects `x: m::x`, 1.42 accepts) + +root := justfile_directory() +lib := "PROVISION_ROOT=" + quote(root) + " bash " + quote(source_directory() / "provision-lib.sh") + +# List the provisioning recipes +default: + @just --list provision + +# Install every tool and dependency this repo needs, then run doctor +setup: + @{{lib}} setup + +# Diagnose the environment: PASS/WARN/FAIL with codes; exits 1 on any FAIL +doctor: + @{{lib}} doctor + +# Apply safe, reversible fixes, then re-run doctor +heal: + @{{lib}} heal + +# Enter a shell with every tool on PATH (guix shell -m manifest.scm, else mise) +dev-shell: + @{{lib}} dev-shell + +# Weekly: bump mise 'latest' resolutions, re-lock, re-pin guix channels +toolchain-refresh: + @{{lib}} toolchain-refresh + +# Print (and copy) the one line to give any AI to install this repo +ai-setup: + @{{lib}} ai-setup + +# Print (and copy) the warm-up for an AI session: user, dev or maintainer +ai-warmup who="user": + @{{lib}} ai-warmup {{quote(who)}} + +# Run test + bench, time both, and write .eval/.txt +eval: + @{{lib}} eval + +# Show where this repo's configuration and toolchain are declared +config-show: + @{{lib}} config-show + +# How to install this repo with OPSM (odds-and-sods package manager) +opsm: + @{{lib}} opsm + +# Print the languages detected from marker files +langs: + @{{lib}} langs + +# Approximate search across tracked files (agrep, else git grep) +search pattern: + @{{lib}} search {{quote(pattern)}} + +# Per-language defaults for the contract verbs, reached as `just provision::` +# or through a root recipe that delegates (`build: provision::build`). A root +# recipe with its own body is the repo-specific override. + +# Build with every detected language's toolchain +build: + @{{lib}} lang-run build + +# Run every detected language's tests +test: + @{{lib}} lang-run test + +# Run every detected language's benchmarks +bench: + @{{lib}} lang-run bench + +# Lint every detected language +lint: + @{{lib}} lang-run lint + +# Format every detected language's sources +fmt: + @{{lib}} lang-run fmt + +# Check formatting without rewriting anything (the CI form of fmt) +fmt-check: + @{{lib}} lang-run fmt-check + +# Run the project (N/A for libraries) +run: + @{{lib}} lang-run run + +# Fetch every detected language's dependencies +deps: + @{{lib}} lang-run deps diff --git a/standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl b/standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl new file mode 100644 index 0000000..9b58c2d --- /dev/null +++ b/standards/provisioning/templates/docs/AI_INSTALLATION_GUIDE.adoc.tmpl @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += AI Installation Guide — __APP_NAME__ +:toc: + +[IMPORTANT] +==== +*This file is written for an AI assistant.* A person asked you to set up +__APP_NAME__ (https://github.com/__REPO_SLUG__). Follow these steps in order. Run +each command yourself if you can run commands; otherwise give the person one +command at a time and wait for its output. Never skip the privacy notice (step 2) +or the verification (step 6). If a step fails, go to <>; do not +improvise a different installer. +==== + +== What this is + +__APP_NAME__ is a __ARCHETYPE__ written in __LANGS__. +__SPEC_WHAT_IT_IS__ + +== 1. Ask the person + +Ask only these, then proceed: + +. *Which operating system?* Linux (which distribution), macOS, or Windows. On +Windows, the steps run inside WSL2; if they have no WSL2, have them run +`wsl --install` in an administrator PowerShell and reboot first. +. *Where should it live?* Default: `~/src/__APP_NAME__`. +__SPEC_QUESTIONS__ + +== 2. Tell the person what will happen (privacy notice) + +Show them this, and continue only after they agree: + +____ +Setting up __APP_NAME__ will: + +* clone https://github.com/__REPO_SLUG__ into the folder you chose; +* install developer tools with mise into `~/.local/share/mise` (per-user, no root), + from their official releases: __MISE_TOOLS__; +* fetch this project's own dependencies from their public registries. + +__SPEC_PRIVACY__ + +It will not: change system settings, need administrator rights (except the OS +packages in step 3, which you approve individually), send data anywhere, or +install anything outside the folders above. +____ + +== 3. Prerequisites + +Check each; install only what is missing. + +[source,bash] +---- +git --version # any version +mise --version # any version +---- + +* Missing git: Fedora `sudo dnf install git` · Debian/Ubuntu `sudo apt install git` · macOS `xcode-select --install`. +* Missing mise: use the OS package manager (macOS `brew install mise` · Fedora + `sudo dnf copr enable jdxcode/mise && sudo dnf install mise` · Windows + `winget install jdx.mise` · others: https://mise.jdx.dev/installing-mise.html). + Only if none fits: download the installer, show it to the user, and run it + once they agree: `curl -fsSLo mise-install.sh https://mise.run && sh mise-install.sh`. + Never pipe a download into a shell. Then run the activation line it + prints (e.g. `echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc`) + and open a new shell. +* A C toolchain, if `cc --version` fails: Fedora `sudo dnf install gcc` · Debian/Ubuntu `sudo apt install build-essential`. +__SYSTEM_DEPS_AI__ + +== 4. Fetch and set up + +[source,bash] +---- +git clone https://github.com/__REPO_SLUG__.git ~/src/__APP_NAME__ +cd ~/src/__APP_NAME__ +./launcher.sh --setup +---- + +`./launcher.sh --setup` installs `just` if needed, runs `mise install`, fetches +this project's dependencies, and ends with `just doctor`. It may take several +minutes the first time; that is normal. + +== 5. If you cannot run the launcher + +Run the same steps directly: + +[source,bash] +---- +mise trust && mise install +just setup +---- + +The full manual route, with every step explained, is `docs/SETUP.adoc`. + +== 6. Verify + +[source,bash] +---- +just doctor +---- + +Success is: exit status 0 and the last line reads `__APP_NAME__: N PASS, N WARN, 0 FAIL`. +WARN lines are advice; mention them to the person but they do not block use. +Then confirm it works: + +[source,bash] +---- +__SPEC_VERIFY__ +---- + +== 7. Tell the person how to use it + +__SPEC_USAGE__ + +Every task is a `just` recipe; `just` lists them. `./launcher.sh --help` explains +the launcher. + +== Uninstall + +[source,bash] +---- +rm -rf ~/src/__APP_NAME__ # the checkout (and everything it built) +---- + +Tools mise installed are shared with other projects; remove one only if nothing +else uses it: `mise uninstall @` (`mise ls` lists them). +__SPEC_UNINSTALL__ + +[[if-a-step-fails]] +== If a step fails + +. Run `just doctor` and read the code on each FAIL line (PV-E…). +. Look it up in `docs/SETUP.adoc` §Troubleshooting and apply the fix. `just heal` + applies every automatic fix. +. Re-run `just doctor`. +. If it still fails, save `just doctor > doctor.txt 2>&1` and help the person open + an issue at https://github.com/__REPO_SLUG__/issues with that file. diff --git a/standards/provisioning/templates/docs/SETUP.adoc.tmpl b/standards/provisioning/templates/docs/SETUP.adoc.tmpl new file mode 100644 index 0000000..71de1d1 --- /dev/null +++ b/standards/provisioning/templates/docs/SETUP.adoc.tmpl @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += Setting up __APP_NAME__ by hand +:toc: +:toclevels: 2 + +This is the complete manual route: every step, in order, with nothing assumed. +You do not need it if you use `./launcher.sh --setup` (or ask an AI, see +link:AI_INSTALLATION_GUIDE.adoc[AI_INSTALLATION_GUIDE.adoc]), but those do +exactly what is written here, so this page is also what to read when they stop. + +[horizontal] +Repository:: https://github.com/__REPO_SLUG__ +Kind:: __ARCHETYPE__ (__LANGS__) +Toolchain declared in:: `mise.toml` (versions `latest`, made concrete in `mise.lock`), and +`__GUIX_PREFIX__manifest.scm` / `__GUIX_PREFIX__guix.scm` / `__GUIX_PREFIX__channels.scm` for Guix +Every command below is also a `just` recipe:: run `just` to list them + +[[prerequisites]] +== 1. Prerequisites + +You need three things from your operating system. Everything else is installed +for you in step 3. + +[cols="1,2,3"] +|=== +|Tool |Why |Install + +|git +|to fetch the repository +|Fedora `sudo dnf install git` · Debian/Ubuntu `sudo apt install git` · macOS `xcode-select --install` · Windows `winget install Git.Git` + +|a C toolchain +|some language toolchains link native code +|Fedora `sudo dnf install gcc` · Debian/Ubuntu `sudo apt install build-essential` · macOS (included with the Xcode tools above) · Windows: use WSL2 + +|mise +|installs every other tool at the version this repo declares +|macOS `brew install mise` · Fedora `sudo dnf copr enable jdxcode/mise && sudo dnf install mise` · Windows `winget install jdx.mise` · Debian/Ubuntu and the rest: https://mise.jdx.dev/installing-mise.html — or download the installer, read it, then run it: `curl -fsSLo mise-install.sh https://mise.run && less mise-install.sh && sh mise-install.sh`. Then follow the one line it prints to activate it in your shell +|=== + +NOTE: On Windows, use WSL2 (`wsl --install`) and follow the Linux steps inside +it. The launcher detects the platform and says so in `./launcher.sh --version`. + +__SYSTEM_DEPS_SECTION__ + +== 2. Get the code + +[source,bash] +---- +git clone https://github.com/__REPO_SLUG__.git +cd __APP_NAME__ +---- + +Or with OPSM, the odds-and-sods package manager (see <>): +`opsm install https://github.com/__REPO_SLUG__.git --registry git` + +== 3. Install the toolchain + +[source,bash] +---- +mise trust # allow this repository's mise.toml +mise install # installs everything mise.toml lists, at mise.lock's versions +---- + +That installs: + +[cols="1,2,3"] +|=== +|Language |Tools |If mise cannot supply it + +__TOOL_TABLE__ +|=== + +`just` itself is installed by `mise install`. Without mise, install just from your +OS instead (`dnf install just`, `brew install just`, `cargo install just`); this +repository needs just 1.42 or newer. + +== 4. Set it up + +[source,bash] +---- +just setup +---- + +`just setup` repeats step 3 (harmless), fetches this repository's own +dependencies (__LANGS__), and finishes by running `just doctor`. + +== 5. Check it + +[source,bash] +---- +just doctor # PASS / WARN / FAIL for every requirement, with the fix for each +---- + +`just doctor` exits 0 when nothing FAILs. A WARN is advice, not a failure. +Every code it prints is explained in <>. + +== 6. Use it + +[source,bash] +---- +just build # build +just test # run the tests +just bench # run the benchmarks (says N/A when there are none) +just eval # tests + benchmarks with timings, saved under .eval/ +just run # run it (says N/A for a library) +just config-show # where the toolchain and config are declared +---- + +Anything that does not apply to a __ARCHETYPE__ says so and exits 0; nothing is faked. + +== 7. Keep it current + +[source,bash] +---- +just toolchain-refresh # mise up --bump, re-lock mise.lock, re-pin __GUIX_PREFIX__channels.scm +just heal # re-apply safe fixes if something drifted +---- + +[[guix]] +== The Guix route (alternative to steps 3–4) + +With GNU Guix installed you can skip mise for everything Guix packages: + +[source,bash] +---- +guix shell -m __GUIX_PREFIX__manifest.scm # the development shell +guix time-machine -C __GUIX_PREFIX__channels.scm -- shell -m __GUIX_PREFIX__manifest.scm # the exact, pinned Guix +guix build -f __GUIX_PREFIX__guix.scm # the package +---- + +Tools Guix does not package: __GUIX_GAPS__. The Guix shell includes mise, so inside +it run `mise install` for those. `just dev-shell` picks Guix when it is present and mise +when it is not. + +[[opsm]] +== OPSM (odds-and-sods package manager) + +[source,bash] +---- +opsm install https://github.com/__REPO_SLUG__.git --registry git +---- + +__APP_NAME__ has no registry package, so OPSM fetches it through its git +adapter; then provision it with `./launcher.sh --setup`. To get OPSM itself +(Erlang/OTP 26+ and Elixir 1.16+ required): + +[source,bash] +---- +git clone https://github.com/hyperpolymath/odds-and-sods-package-manager.git +cd odds-and-sods-package-manager/opsm_ex +mix deps.get && mix escript.build +install -m 0755 opsm ~/.local/bin/opsm +opsm --version +---- + +`just opsm` prints the same instructions. + +[[troubleshooting]] +== Troubleshooting + +Run `just doctor`, find the code it printed, apply the fix. `just heal` applies +every fix marked *auto* for you. + +[cols="1,3,3,1"] +|=== +|Code |Meaning |Fix |Auto + +|PV-E01 |git is not installed |Install git (step 1) |no +|PV-E02 |just is missing or older than 1.42 |`mise use -g just@latest` |no +|PV-E03 |a tool in mise.toml is not installed |`just setup` (or `mise install`) |yes +|PV-E10 |a language toolchain binary is missing |`just setup`; the doctor line names the manual route |yes, via mise +|PV-E11 |a system dependency from the provisioning deed is missing |install it from your OS (see <>) |no +|PV-E20 |mise.toml is missing |restore it: `git checkout -- mise.toml` |no +|PV-E27 |launcher.sh is not executable |`chmod +x launcher.sh` |yes +|PV-E40 |neither Guix nor mise is installed, so there is no dev shell |install mise (step 1) |no +|PV-E41 |`just toolchain-refresh` could not regenerate `build/guix/crates.scm`: the Guix crate importer defined fewer crates than `Cargo.lock` lists (often a network failure or a crate not yet on crates.io) |re-run `just toolchain-refresh`; the error is printed after the code. The old file is kept, so the build is unaffected |no +|PV-E50 |this repository's own `doctor-local` checks failed |read the output above the summary line; the recipe is in the Justfile |no +|PV-E51 |`build/just/doctor-local.sh` exited before its end (an `exit` or a failing command under `set -e`) |the doctor names the exit status; fix the script so it reports with pass/warn/fail instead of exiting |no +|PV-W01 |mise is not installed |install mise (step 1) |no +|PV-W20 |mise.lock is missing, so `latest` is not concrete |`just toolchain-refresh` |yes +|PV-W21 |both mise.toml and .mise.toml exist |merge into mise.toml, delete .mise.toml |no +|PV-W22 |a .tool-versions file competes with mise.toml |fold it into mise.toml |no +|PV-W23 |a mise config (mise.toml, .mise.toml, .tool-versions) pins a tool the estate does not use, or an npm:/pipx:/pip:/go: backend |remove that line |no +|PV-W24 |a Guix file is an unfilled template |regenerate it (maintainers: `provision-set realign`) |no +|PV-W25 |guix.scm is missing |as PV-W24 |no +|PV-W26 |manifest.scm is missing |as PV-W24; mise still works |no +|PV-W27 |launcher.sh is missing |as PV-W24 |no +|PV-W28 |a setup document is missing |as PV-W24 |no +|PV-W29 |a setup document still holds unfilled template slots |replace each `__SPEC_…__` slot with the facts for this repository |no +|PV-W30 |Deno files remain |the estate runtime is bun; a migration issue tracks it |no +|PV-W31 |Nix files remain |use Guix |no +|PV-W32 |a Makefile remains |use the Justfile |no +|PV-W33 |Python files remain |not an estate language |no +|PV-W34 |npm/yarn/pnpm/TypeScript files remain |use bun |no +|PV-W35 |both guix.scm and build/guix.scm exist |keep one; the engine uses build/ |no +|=== + +Still stuck? `just doctor > doctor.txt 2>&1` and open an issue at +https://github.com/__REPO_SLUG__/issues with that file attached. diff --git a/standards/provisioning/templates/guix/channels.scm b/standards/provisioning/templates/guix/channels.scm new file mode 100644 index 0000000..54ac61a --- /dev/null +++ b/standards/provisioning/templates/guix/channels.scm @@ -0,0 +1,18 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; channels.scm — the Guix revision this repository's guix.scm and manifest.scm +;; were verified against. Reproduce that exact Guix with: +;; +;; guix time-machine -C channels.scm -- shell -m manifest.scm +;; +;; Refresh it (after re-verifying) with: just toolchain-refresh +;; Canon pin, verified 2026-09-30 (docker.io/metacall/guix, guix describe). +(list (channel + (name 'guix) + (url "https://codeberg.org/guix/guix.git") + (branch "master") + (commit "ae77aeb9543de2661d739104bc4d3803d8c6f38b") + (introduction + (make-channel-introduction + "9edb3f66fd807b096b48283debdcddccfea34bad" + (openpgp-fingerprint + "BBB0 2DDF 2CEA F6A8 0D1D E643 A2A0 6DF2 A33A 54FA"))))) diff --git a/standards/provisioning/templates/guix/guix.scm.cargo.tmpl b/standards/provisioning/templates/guix/guix.scm.cargo.tmpl new file mode 100644 index 0000000..29c9998 --- /dev/null +++ b/standards/provisioning/templates/guix/guix.scm.cargo.tmpl @@ -0,0 +1,40 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; guix.scm — builds __APP_NAME__ hermetically with Guix (offline, from Cargo.lock). +;; +;; guix build -f guix.scm # the package +;; guix shell -D -f guix.scm # its build environment +;; +;; Crate inputs live in build/guix/crates.scm, generated from Cargo.lock by +;; guix import crate --lockfile=Cargo.lock __APP_NAME__ +;; `just toolchain-refresh` regenerates it; never hand-edit it. +(use-modules (guix packages) (guix gexp) + (guix build-system cargo) + ((guix licenses) #:prefix license:)) + +;; The repository root: this file sits at the root or in build/ (PROVISIONING-STANDARD §1). +(define %source-dir + (let ((d (dirname (current-filename)))) + (if (string=? (basename d) "build") (dirname d) d))) +(load (string-append %source-dir "/build/guix/crates.scm")) ; defines %crate-inputs + +;; Build products and caches never enter the source (git-predicate is not used: +;; it breaks on tarball checkouts and on bind-mounted trees). +(define %ignored + '(".git" "target" ".eval" "node_modules" "_build" "deps" "zig-out" ".zig-cache" "dist-newstyle")) + +(package + (name "__APP_NAME__") + (version "__APP_VERSION__") + (source (local-file %source-dir "__APP_NAME__-checkout" + #:recursive? #t + #:select? (lambda (file stat) + (not (member (basename file) %ignored))))) + (build-system cargo-build-system) + (arguments (list #:install-source? #f)) + (inputs %crate-inputs) + (home-page "https://github.com/__REPO_SLUG__") + (synopsis "__SYNOPSIS__") + (description "__DESCRIPTION__") + (license __GUIX_LICENSE__)) diff --git a/standards/provisioning/templates/guix/guix.scm.source.tmpl b/standards/provisioning/templates/guix/guix.scm.source.tmpl new file mode 100644 index 0000000..bfc5433 --- /dev/null +++ b/standards/provisioning/templates/guix/guix.scm.source.tmpl @@ -0,0 +1,43 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; guix.scm — __APP_NAME__ as a Guix package. +;; +;; guix build -f guix.scm # installs the source tree to share/__APP_NAME__ +;; guix shell -D -f guix.scm # the full development toolchain +;; +;; This is a SOURCE package, and says so: a hermetic compiled build needs this +;; repository's __LANGS__ dependencies packaged in Guix, which they are not +;; (Guix builds offline). The toolchain below is real — `guix shell -D -f guix.scm` +;; then `just setup` gives a working environment. See docs/SETUP.adoc §Guix. +(use-modules (guix packages) (guix gexp) + (guix build-system copy) + (gnu packages) + ((guix licenses) #:prefix license:)) + +;; The repository root: this file sits at the root or in build/ (PROVISIONING-STANDARD §1). +(define %source-dir + (let ((d (dirname (current-filename)))) + (if (string=? (basename d) "build") (dirname d) d))) +;; A spec may name an output ("rust:cargo"); plain specification->package cannot. +(define (spec->input spec) + (call-with-values (lambda () (specification->package+output spec)) + (lambda (pkg out) (if (string=? out "out") pkg (list pkg out))))) + +(define %ignored + '(".git" "target" ".eval" "node_modules" "_build" "deps" "zig-out" ".zig-cache" "dist-newstyle")) + +(package + (name "__APP_NAME__") + (version "__APP_VERSION__") + (source (local-file %source-dir "__APP_NAME__-checkout" + #:recursive? #t + #:select? (lambda (file stat) + (not (member (basename file) %ignored))))) + (build-system copy-build-system) + (arguments (list #:install-plan #~'(("." "share/__APP_NAME__/")))) + (native-inputs (map spec->input (list __GUIX_PKG_SPECS__))) + (home-page "https://github.com/__REPO_SLUG__") + (synopsis "__SYNOPSIS__") + (description "__DESCRIPTION__") + (license __GUIX_LICENSE__)) diff --git a/standards/provisioning/templates/guix/manifest.scm.tmpl b/standards/provisioning/templates/guix/manifest.scm.tmpl new file mode 100644 index 0000000..f465e7e --- /dev/null +++ b/standards/provisioning/templates/guix/manifest.scm.tmpl @@ -0,0 +1,14 @@ +;; SPDX-License-Identifier: __LICENSE__ +;; SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +;; +;; manifest.scm — the Guix development shell for __APP_NAME__. +;; +;; guix shell -m manifest.scm # or: just dev-shell +;; guix time-machine -C channels.scm -- shell -m manifest.scm # the pinned Guix +;; +;; Minted by provision-set from the languages detected here (__LANGS__). +;; Tools Guix does not package: __GUIX_GAPS__. They come from mise, which this +;; shell provides: guix shell -m manifest.scm -- mise install +;; Canon: hyperpolymath/standards 3-practice/provisioning/PROVISIONING-STANDARD.adoc +(specifications->manifest + (list __GUIX_SPECS__)) diff --git a/standards/provisioning/templates/launcher.sh.tmpl b/standards/provisioning/templates/launcher.sh.tmpl new file mode 100755 index 0000000..32e0243 --- /dev/null +++ b/standards/provisioning/templates/launcher.sh.tmpl @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: __LICENSE__ +# SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +# +# @launcher-deed begin +# ;; SPDX-License-Identifier: __LICENSE__ +# (praxis-deed +# :schema-version "1.0.0" +# :canonical-name "__APP_NAME__-launcher" +# :beholding-chora #u5"estate/chora" +# (artefact :type "launcher" :version "__APP_VERSION__" +# :generator "provision-set") +# (app :name "__APP_NAME__" :display "__APP_NAME__" +# :url "https://github.com/__REPO_SLUG__" :archetype "__ARCHETYPE__") +# (compliance :standard-version "0.6.0" +# :standards ("launcher-standard.adoc" +# "PROVISIONING-STANDARD.adoc")) +# (modes :accepted ("--setup" "--doctor" "--heal" "--ai-setup" "--help" "--version" +# "--start" "--stop" "--status" "--auto" "--integ" "--disinteg")) +# (platforms :supported ("linux" "macos" "windows")) +# (lifecycle-phases :covered ("provision" "diagnose" "heal") +# :deferred ("install" "run"))) +# @launcher-deed end +# +# The launcher for a library / tool / theory / docs repository +# (launcher-standard 0.6.0, archetype profile). Every mode is implemented in +# build/just/provision-modes.sh, and every provisioning mode delegates to the +# Justfile. This file is minted by `provision-set`; do not hand-edit it. +# Repo-specific facts belong in .machine_readable/descriptiles/provisioning_praxis.deed. +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +if [ ! -f "$REPO_DIR/build/just/provision-modes.sh" ]; then + echo "launcher.sh: build/just/provision-modes.sh is missing — this checkout is incomplete." >&2 + echo "Re-clone, or restore it: git checkout -- build/just/provision-modes.sh" >&2 + exit 1 +fi +# shellcheck source=build/just/provision-modes.sh +. "$REPO_DIR/build/just/provision-modes.sh" + +hp_launcher_main "$@" diff --git a/standards/provisioning/templates/llm-warmup-dev.adoc.tmpl b/standards/provisioning/templates/llm-warmup-dev.adoc.tmpl new file mode 100644 index 0000000..c85e118 --- /dev/null +++ b/standards/provisioning/templates/llm-warmup-dev.adoc.tmpl @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += LLM warm-up — __APP_NAME__ (developer) + +Paste this into an AI assistant before changing code in __APP_NAME__. + +== Orientation + +__APP_NAME__ (https://github.com/__REPO_SLUG__): a __ARCHETYPE__ in __LANGS__. + +__SPEC_ARCHITECTURE__ + +== Toolchain + +* Declared in `mise.toml` (specs `latest`), made concrete in `mise.lock`. Never + hand-edit versions; `just toolchain-refresh` bumps and re-locks. +* Guix alternative: `__GUIX_PREFIX__manifest.scm` (dev shell), `__GUIX_PREFIX__guix.scm` (package), + `__GUIX_PREFIX__channels.scm` (pinned Guix). `just dev-shell` picks whichever is present. +* `just` ≥ 1.42. The shared recipes live in `build/just/provision.just` + (`mod provision`); root recipes in `Justfile` override them. + +== The loop + +[source,bash] +---- +just setup # once +just build +just test +just bench # N/A when there are no benchmarks +just eval # test + bench with timings, logged under .eval/ +just lint && just fmt +just doctor # before you open a PR +---- + +__SPEC_DEV_COMMANDS__ + +== Where things are + +__SPEC_LAYOUT__ + +== Rules that bite + +* Languages: bun is the JS runtime; Python, Deno, TypeScript, ReScript, Nix, + Go and Makefiles are banned. New code follows the repository's existing languages. +* Every new file carries an SPDX header: `__LICENSE__` for code and config, + `__DOC_LICENSE__` for prose docs. Never change an existing file's licence. +* Commits are signed; history is linear (squash merges). + +__SPEC_GOTCHAS__ diff --git a/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl b/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl new file mode 100644 index 0000000..4295ea3 --- /dev/null +++ b/standards/provisioning/templates/llm-warmup-maintainer.adoc.tmpl @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += LLM warm-up — __APP_NAME__ (maintainer) + +Paste this into an AI assistant before release, CI or governance work on +__APP_NAME__. Read `llm-warmup-dev.adoc` first; this adds what a maintainer owns. + +== The provisioning set + +This repository is provisioned from the estate canon +(https://github.com/hyperpolymath/standards, `3-practice/provisioning/`). +Repo-specific facts live in `.machine_readable/descriptiles/provisioning_praxis.deed`. +`provision-set realign` overwrites only the engine, `build/just/provision*`; +change behaviour through the deed, never by hand-editing those. It re-renders +`launcher.sh` only when that file carries the `@launcher-deed` block. +`build/guix/crates.scm` is generated from `Cargo.lock`: regenerate it, never +edit it. Everything else (`channels.scm`, re-pinned by `toolchain-refresh`; +`guix.scm` once real, `manifest.scm`, `mise.toml`, the Justfile, `docs/` and +these warm-ups) is minted once and then belongs to this repository: keep it +true as the code changes. + +== Keeping it current + +* `just toolchain-refresh` — `mise up --bump`, `mise lock`, re-pin the guix commit in `channels.scm`. + Commit the lock changes on their own. +* `just doctor` must be clean before a release; WARN codes are tracked, not ignored. +* The `provisioning-check` workflow reports drift from the canon. + +== Release + +__SPEC_RELEASE__ + +== CI and branch rules + +__SPEC_CI__ + +Rulesets can block a merge for reasons other than checks (review threads, +signatures, code owners): enumerate the ruleset's rule types before calling a PR +mergeable. + +== Decisions + +Owner rulings for the estate are recorded in `hyperpolymath/standards` +(issue #787 is the decision surface). Do not re-open a ruled question; cite it. +__SPEC_DECISIONS__ diff --git a/standards/provisioning/templates/llm-warmup-user.adoc.tmpl b/standards/provisioning/templates/llm-warmup-user.adoc.tmpl new file mode 100644 index 0000000..64f149c --- /dev/null +++ b/standards/provisioning/templates/llm-warmup-user.adoc.tmpl @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: __DOC_LICENSE__ +// SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ += LLM warm-up — __APP_NAME__ (user) + +Paste this whole file into any AI assistant before asking it about +__APP_NAME__. It tells the assistant what the project is, how a person gets it +running, and where to look when something goes wrong. + +== What it is + +__APP_NAME__ (https://github.com/__REPO_SLUG__) is a __ARCHETYPE__ written in __LANGS__. + +__SPEC_WHAT_IT_IS__ + +== Getting it running + +The supported routes, in order of ease: + +. *Ask an AI*: follow `docs/AI_INSTALLATION_GUIDE.adoc` in the repository. +. *The launcher*: `git clone https://github.com/__REPO_SLUG__.git && cd __APP_NAME__ && ./launcher.sh --setup` +. *By hand*: `docs/SETUP.adoc`, every step explained. + +Prerequisites are git, a C toolchain and mise; everything else is installed by +`mise install`, at the versions pinned in `mise.lock`. + +== Using it + +__SPEC_USAGE__ + +== When something is wrong + +Run `just doctor`. Every FAIL line carries a code (PV-E…) that is explained, with +its fix, in `docs/SETUP.adoc` §Troubleshooting. `just heal` applies the automatic +fixes. Do not guess an installer that the repository does not document. + +== What it touches + +__SPEC_PRIVACY__ diff --git a/standards/provisioning/templates/mise.toml.tmpl b/standards/provisioning/templates/mise.toml.tmpl new file mode 100644 index 0000000..be4bc98 --- /dev/null +++ b/standards/provisioning/templates/mise.toml.tmpl @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: __LICENSE__ +# SPDX-FileCopyrightText: __YEAR__ __COPYRIGHT_HOLDER__ +# +# The toolchain for __APP_NAME__ (__LANGS__). Specs are `latest`; mise.lock +# records the concrete version and per-platform checksum of each, so every +# checkout installs the same bits. Refresh both with: just toolchain-refresh +# Canon: hyperpolymath/standards 3-practice/provisioning (TRUST-DEFAULTS §toolchain). +# Estate policy: no python, deno, node/npm/yarn, make or nix here; the JS runtime is bun. + +[settings] +lockfile = true + +[tools] +__MISE_TOOLS_TOML__