stack-release refuses a user-unit activate, rollback or pm-on when the instance shows any accounts sign (stamped target, accounts.json, console.sso, .deployed), before anything stops. Root renders the system units with case2-unit.sh, and the op runs with --units external. case2-unit renders a stamped copy's units only with --sso, console.sso, an accounts.json that loads and a valid .deployed, and names each missing step in run-sheet order before any write. Per Jason's ruling of 2026-11-10, a stamped library unit takes --sso and refuses proxy-preview admission and its proof. The unstamped and dev paths are unchanged. rehearse runs a stamped copy against rehearsal.invalid with scratch slot placeholders, a copy of the live accounts.json and the copy's own .deployed. It refuses sso-not-prepared and accounts-not-initialized before any rehearsal directory exists, and sso-isolated before the host starts. The tests that turned SSO on through the user unit now pin the refusal and reach the same end state through the system path. The copy tests start the Console on real scratch prerequisites and pin each refusal. The run sheet, TOOLS.md and the accounts design record the ruling and the migration order.
266 lines
16 KiB
Bash
Executable File
266 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Case 2 row C2-13b (design 10 step 2b): a hub's install, run as <user>
|
|
# after root's 2a and before root's 2c renders the units.
|
|
#
|
|
# case2-install.sh --business ID --business-file FILE --commit SHA
|
|
# [--profile factory|library] [--personas DIR] [--home DIR]
|
|
#
|
|
# --org ID is the same option as --business ID, and --org-file FILE the same
|
|
# as --business-file FILE. They are two families: each spelling once, and a
|
|
# second spelling of a family must carry the same text.
|
|
#
|
|
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
|
|
# SHA, whose npm ci installs the pinned harness; for library, the persona
|
|
# import's install from the staging DIR; the business file at 0600; for
|
|
# library, the empty 0700 slot dir <configDir>/secrets/<id>/; `mosaic
|
|
# business validate`; then the next steps. It runs no release package or
|
|
# activate, starts no unit, writes no unit file and nothing under /etc, and
|
|
# never writes the library token, which is Jason's step 4.
|
|
#
|
|
# FILE is the business document written for the host from the go. For a library,
|
|
# the script adds chief identity/publish and roles.bean.vars and roles.chet.vars persona.name and
|
|
# persona.sha256, from the staging manifest, and checks the input against
|
|
# the precheck's profile rules first: library needs a library project,
|
|
# bus.tester off and bean and chet as companion roles with no persona var;
|
|
# factory needs no library project and bus.tester on.
|
|
#
|
|
# Every refusal it can see before writing comes first, so a refused run
|
|
# writes nothing. A re-run passes when the business file and personas on
|
|
# disk match what it would write, and refuses business-file-differs or
|
|
# persona-differs when they don't, touching neither. If validate refuses,
|
|
# the business file goes only when this run created it.
|
|
#
|
|
# --home must be $HOME. The config dir is dirname of $MOSAIC_CONFIG, or
|
|
# <home>/.config/mosaic-dev; the install makes it 0700 when absent and
|
|
# refuses config-dir-mode when it has a group or other bit.
|
|
#
|
|
# Test seam, for packages/cli/tests/case2-install.test.mjs only:
|
|
# CASE2_STACK_COPY the copy script, default scripts/stack-copy.sh
|
|
#
|
|
# Exit 0 ok, 1 refused, 4 usage.
|
|
set -uo pipefail
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
|
|
|
|
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
|
|
# shellcheck source=scripts/selector.sh
|
|
. "$REPO/scripts/selector.sh" || { echo "case2-install: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
|
|
# The selectors (rename design r2): the id family and the file family are separate scopes. A
|
|
# missing value is a usage error; a flag-shaped or invalid id is the business-id refusal below; a
|
|
# flag-shaped file, a repeated spelling and a conflict say why. All exit 4, as before.
|
|
id_refused() {
|
|
case "$1" in
|
|
missing) usage ;;
|
|
flag|invalid) echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4 ;;
|
|
*) echo "case2-install: $2" >&2; exit 4 ;;
|
|
esac
|
|
}
|
|
file_refused() {
|
|
case "$1" in
|
|
missing) usage ;;
|
|
*) echo "case2-install: $2" >&2; exit 4 ;;
|
|
esac
|
|
}
|
|
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; }
|
|
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--business|--org) BUSINESS="${2-}"; selector_take id id id_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
|
|
--business-file|--org-file) selector_take file file file_refused - "$1" "${@:2:1}"; BFILE="$2"; shift 2 ;;
|
|
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
|
|
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
|
|
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
|
|
--home) [ $# -ge 2 ] || usage; HOME_ARG="$2"; shift 2 ;;
|
|
*) usage ;;
|
|
esac
|
|
done
|
|
[ -n "$BUSINESS" ] && [ -n "$BFILE" ] && [ -n "$COMMIT" ] || usage
|
|
case "$PROFILE" in factory|library) ;; *) echo "case2-install: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac
|
|
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]] || { echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4; }
|
|
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]] || { echo "case2-install: --commit must be a full 40-character commit id: $COMMIT" >&2; exit 4; }
|
|
if [ "$PROFILE" = library ] && [ -z "$PERSONAS" ]; then echo 'case2-install: the library profile needs --personas DIR' >&2; exit 4; fi
|
|
if [ "$PROFILE" = factory ] && [ -n "$PERSONAS" ]; then echo 'case2-install: the factory profile takes no --personas' >&2; exit 4; fi
|
|
|
|
say() { echo "case2-install: $1"; }
|
|
refuse() { echo "case2-install: refused: $1" >&2; exit 1; }
|
|
mode_of() { stat -c '%a' -- "$1"; }
|
|
# An existing directory, not a link, owned by this uid, with no group or other bit.
|
|
private_dir() { [ ! -L "$1" ] && [ -d "$1" ] && [ "$(stat -c '%u' -- "$1")" = "$(id -u)" ] && [ $((8#$(mode_of "$1") & 8#077)) -eq 0 ]; }
|
|
# An absent path, or a private directory; anything else refuses CODE.
|
|
absent_or_private() {
|
|
if [ ! -e "$1" ] && [ ! -L "$1" ]; then return 0; fi
|
|
private_dir "$1" || refuse "$2: $1 must be $(id -un)'s directory with no group or other bit"
|
|
}
|
|
|
|
# 1. Never as root: root owns nothing under <home> here.
|
|
[ "$(id -u)" != 0 ] || refuse 'run-as-root: run the install as <user>, not root'
|
|
[ -n "${HOME:-}" ] && [ "$HOME_ARG" = "$HOME" ] || refuse "home-mismatch: --home $HOME_ARG is not \$HOME ${HOME:-unset}"
|
|
CONFIG="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}"
|
|
case "$CONFIG" in /*) ;; *) refuse "config-path: MOSAIC_CONFIG must be an absolute path: $CONFIG" ;; esac
|
|
CONFIG_DIR="$(dirname -- "$CONFIG")"
|
|
SECRETS="$CONFIG_DIR/secrets"
|
|
SLOT="$SECRETS/$BUSINESS"
|
|
COPIES="$HOME/.local/share/mosaic-stack/copies"
|
|
absent_or_private "$CONFIG_DIR" config-dir-mode
|
|
if [ "$PROFILE" = library ]; then
|
|
absent_or_private "$SECRETS" secrets-mode
|
|
absent_or_private "$SLOT" slot-mode
|
|
fi
|
|
|
|
# The plan and the write share one helper. plan checks the input, the
|
|
# staging manifest and what is on disk, and prints one decision per line;
|
|
# write makes the business file with O_EXCL. Neither prints persona text.
|
|
HELPER='
|
|
import { closeSync, constants, fchmodSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
const [verb, id, input, profile, staging, configDir] = process.argv.slice(1);
|
|
const say = (...f) => console.log(f.join("\t"));
|
|
const stop = (code, message) => { say("REFUSE", `${code}: ${message}`); process.exit(0); };
|
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
let doc;
|
|
try { doc = JSON.parse(readFileSync(input, "utf8")); } catch (e) { stop("business-file-input", `cannot read ${input} as JSON (${e.code ?? "parse"})`); }
|
|
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) stop("business-file-input", `${input} is not a JSON object`);
|
|
if (doc.id !== id) stop("business-id-mismatch", `${input} has id ${JSON.stringify(doc.id)}, not ${id}`);
|
|
const projects = doc.projects && typeof doc.projects === "object" ? doc.projects : {};
|
|
const library = Object.keys(projects).filter((k) => projects[k]?.kind === "library");
|
|
const tester = doc.vars?.["bus.tester"] === true;
|
|
let personas = [];
|
|
if (profile === "factory") {
|
|
if (library.length) stop("profile-library-business", `the factory profile refuses library projects ${library.join(",")}`);
|
|
if (!tester) stop("bus-tester-off", "the factory profile needs vars bus.tester true");
|
|
} else {
|
|
if (!library.length) stop("no-library-project", `${input} has no library project`);
|
|
if (tester) stop("bus-tester-on", "the library profile needs bus.tester off");
|
|
// C2-21: the chief mints its own read grant after install; no token or grant is created here.
|
|
const chief = Object.values(doc.roles ?? {}).find((r) => r?.definition === "chief-of-staff");
|
|
if (!chief) stop("chief-missing", "the library profile needs a chief-of-staff instance");
|
|
const project = projects.books?.kind === "library" ? "books" : library[0];
|
|
chief.identity ??= { name: "Chief of staff", email: `${id}[email protected]`,
|
|
gitea: { url: new URL(projects[project].remote).origin, user: `${id}-chief` } };
|
|
chief.publish = [project];
|
|
for (const instance of ["bean", "chet"]) {
|
|
const role = doc.roles?.[instance];
|
|
if (role?.definition !== "companion") stop("companion-roles", `roles.${instance} must have definition companion`);
|
|
if (Object.keys(role.vars ?? {}).some((k) => k.startsWith("persona."))) stop("persona-var-input", `roles.${instance}.vars sets a persona var; the install adds them`);
|
|
}
|
|
let manifest;
|
|
try { manifest = JSON.parse(readFileSync(join(staging, "manifest.json"), "utf8")); } catch (e) { stop("persona-manifest", `cannot read ${join(staging, "manifest.json")} (${e.code ?? "parse"})`); }
|
|
const entries = manifest?.version === 1 && Array.isArray(manifest.personas) ? manifest.personas : [];
|
|
const want = (instance) => entries.filter((m) => m?.instance === instance);
|
|
for (const instance of ["bean", "chet"]) {
|
|
const m = want(instance);
|
|
if (entries.length !== 2 || m.length !== 1 || m[0].name !== instance[0].toUpperCase() + instance.slice(1) || !/^[0-9a-f]{64}$/.test(m[0].sha256 ?? ""))
|
|
stop("persona-manifest", "the staging manifest does not list Bean and Chet once each with a sha256");
|
|
personas.push(m[0]);
|
|
}
|
|
for (const m of personas) doc.roles[m.instance].vars = { ...(doc.roles[m.instance].vars ?? {}), "persona.name": m.name, "persona.sha256": m.sha256 };
|
|
}
|
|
const text = `${JSON.stringify(doc, null, 2)}\n`;
|
|
const file = join(configDir, "businesses", `${id}.json`);
|
|
if (verb === "write") {
|
|
let fd;
|
|
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
catch (e) { stop("business-file-raced", `${file} could not be made new (${e.code})`); }
|
|
fchmodSync(fd, 0o600);
|
|
writeSync(fd, text);
|
|
closeSync(fd);
|
|
say("WROTE", file);
|
|
process.exit(0);
|
|
}
|
|
// plan: the personas on disk, then the business file on disk.
|
|
if (profile === "library") {
|
|
const dir = join(configDir, "personas", id);
|
|
const seen = personas.map((m) => {
|
|
const p = join(dir, `${m.instance}.md`);
|
|
const s = lstatSync(p, { throwIfNoEntry: false });
|
|
if (!s) return "absent";
|
|
return s.isFile() && sha256(readFileSync(p)) === m.sha256 ? "same" : "differs";
|
|
});
|
|
if (seen.every((s) => s === "absent")) say("PERSONAS", "install");
|
|
else if (seen.every((s) => s === "same")) say("PERSONAS", "present");
|
|
else stop("persona-differs", `the persona files under ${dir} are not the staged pair; nothing written`);
|
|
for (const m of personas) say("PERSONA", m.instance, m.name, m.sha256);
|
|
}
|
|
const s = lstatSync(file, { throwIfNoEntry: false });
|
|
if (!s) say("BUSINESS", "new", file);
|
|
else if (s.isFile() && !s.isSymbolicLink() && readFileSync(file, "utf8") === text) say("BUSINESS", "same", file);
|
|
else stop("business-file-differs", `${file} exists and differs from the one this install writes; nothing written`);
|
|
'
|
|
helper() { (cd "$REPO" && node --input-type=module -e "$HELPER" "$1" "$BUSINESS" "$BFILE" "$PROFILE" "$PERSONAS" "$CONFIG_DIR" 2>&1); }
|
|
field() { printf '%s\n' "$plan" | awk -F'\t' -v k="$1" '$1 == k { print $2 }'; }
|
|
|
|
plan="$(helper plan)" || refuse "plan: the check failed: $(printf '%s' "$plan" | head -n 1)"
|
|
r="$(printf '%s\n' "$plan" | awk -F'\t' '$1 == "REFUSE" { print $2; exit }')"
|
|
[ -z "$r" ] || refuse "$r"
|
|
BUSINESS_STATE="$(field BUSINESS)"
|
|
[ -n "$BUSINESS_STATE" ] || refuse "plan: no decision: $(printf '%s' "$plan" | head -n 1)"
|
|
PERSONA_STATE="$(field PERSONAS)"
|
|
|
|
# 2. bootstrap: idempotent, and it never rewrites an existing config.
|
|
if [ ! -e "$CONFIG_DIR" ]; then
|
|
mkdir -p -- "$(dirname -- "$CONFIG_DIR")" && mkdir -m 700 -- "$CONFIG_DIR" || refuse "config-dir: cannot make $CONFIG_DIR"
|
|
fi
|
|
out="$("$REPO/scripts/bootstrap.sh" 2>&1)" || refuse "bootstrap: $(printf '%s' "$out" | tail -n 1)"
|
|
say "config $CONFIG"
|
|
# Case 1 and Case 2 share this installer. Deployment identity is instance-scoped,
|
|
# never a switch that can be silently removed to enable the local dev owner.
|
|
out="$(cd "$REPO" && node --input-type=module -e 'import { readFileSync } from "node:fs"; import { writeDeployment } from "./packages/cli/src/deployment.mjs"; writeDeployment(JSON.parse(readFileSync(process.argv[1], "utf8")));' "$CONFIG" 2>&1)" || refuse "deployment: $(printf '%s' "$out" | tail -n 1)"
|
|
|
|
# 3. The stack copy row 195's units run from.
|
|
out="$("$STACK_COPY" copy "$COMMIT" --copies "$COPIES" 2>&1)" || refuse "stack-copy: $(printf '%s' "$out" | tail -n 1)"
|
|
[ -f "$COPIES/$COMMIT/scripts/mosaic" ] || refuse "stack-copy: no copy $COMMIT in $COPIES after the copy step"
|
|
say "copy $COPIES/$COMMIT"
|
|
|
|
# 4. library: the personas, from the staging dir.
|
|
if [ "$PROFILE" = library ]; then
|
|
if [ "$PERSONA_STATE" = install ]; then
|
|
out="$(node "$COPIES/$COMMIT/scripts/case2-persona-import.mjs" install --staging "$PERSONAS" --config-dir "$CONFIG_DIR" --business "$BUSINESS" 2>&1)" \
|
|
|| refuse "persona-import: $(printf '%s\n' "$out" | awk '/^[[:alnum:]_]*Error|^ERR_/ { print; found=1; exit } { last=$0 } END { if (!found) print last }')"
|
|
while IFS=$'\t' read -r _ inst name sum; do
|
|
printf '%s\n' "$out" | grep -qxF "instance $inst: persona.name $name, persona.sha256 $sum" \
|
|
|| refuse "persona-import: the import did not report $inst with the manifest's sha256"
|
|
done < <(printf '%s\n' "$plan" | awk -F'\t' '$1 == "PERSONA"')
|
|
say "personas installed under $CONFIG_DIR/personas/$BUSINESS"
|
|
else
|
|
say "personas already installed under $CONFIG_DIR/personas/$BUSINESS"
|
|
fi
|
|
fi
|
|
|
|
# 5. The business file, at 0600 and new, unless an equal one is there.
|
|
CREATED=0
|
|
if [ "$BUSINESS_STATE" = new ]; then
|
|
mkdir -p -m 700 -- "$CONFIG_DIR/businesses" || refuse "business-file: cannot make $CONFIG_DIR/businesses"
|
|
w="$(helper write)" || refuse "business-file: $(printf '%s' "$w" | head -n 1)"
|
|
case "$w" in WROTE*) CREATED=1 ;; REFUSE*) refuse "${w#REFUSE }" ;; *) refuse "business-file: $(printf '%s' "$w" | head -n 1)" ;; esac
|
|
say "business file $CONFIG_DIR/businesses/$BUSINESS.json written"
|
|
else
|
|
say "business file $CONFIG_DIR/businesses/$BUSINESS.json already matches"
|
|
fi
|
|
|
|
# 6. library: the empty slot dir. The token is Jason's, design 10 step 4.
|
|
if [ "$PROFILE" = library ]; then
|
|
[ -e "$SECRETS" ] || mkdir -m 700 -- "$SECRETS" || refuse "slot: cannot make $SECRETS"
|
|
[ -e "$SLOT" ] || mkdir -m 700 -- "$SLOT" || refuse "slot: cannot make $SLOT"
|
|
private_dir "$SLOT" || refuse "slot-mode: $SLOT must be a 0700 directory"
|
|
say "slot dir $SLOT"
|
|
fi
|
|
|
|
# 7. validate, the business package's own schema.
|
|
if ! out="$("$REPO/scripts/mosaic" business validate "$BUSINESS" 2>&1)"; then
|
|
if [ "$CREATED" = 1 ]; then rm -f -- "$CONFIG_DIR/businesses/$BUSINESS.json"; fi
|
|
refuse "validate: $(printf '%s' "$out" | tail -n 1)$( [ "$CREATED" = 1 ] && echo '; the business file this run wrote is removed')"
|
|
fi
|
|
say "business $BUSINESS validates"
|
|
|
|
# 8. What comes next. Root's steps run from root's own checkout. The copy is stamped and .deployed
|
|
# names this instance (step 2), so its Console starts only with SSO accounts (M3, #1873). The steps
|
|
# before root's are the run sheet's (section 7), and nothing here does them: SSO is the operator's
|
|
# config, accounts.json comes only from the human verb, and case2-unit.sh refuses until both exist.
|
|
say "done. .deployed names this instance and the copy is stamped, so its Console starts only with SSO accounts. Before the units, in order (run sheet section 7): prepare console.sso and its client secret slots in $CONFIG; a human runs \`mosaic accounts init\` as $(id -un); $(id -un) stays a dedicated uid with a non-login shell, the only member of its primary group."
|
|
say "Next: root runs case2-unit.sh --copy $COMMIT --business $BUSINESS with --sso in the serve flags (factory: --sso --send; add --proxy when the config has a proxy section; design 10 step 2c), then 2d and 3. It refuses before any write and names each step still missing."
|
|
if [ "$PROFILE" = library ]; then
|
|
say "Then Jason places the library token in $SLOT (step 4), and <user> runs case2-precheck.sh --business $BUSINESS --library-status (step 4a)."
|
|
fi
|
|
exit 0
|