Review "changes 8" fix round for org-redact6. - launcher: a harness module's throw names its system code or class, never its message; preflight, loader, bundle, checkout, sandbox and route text is relayed with host paths replaced (relay()). - claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the repository's node_modules, never a path; persona and skill bundle errors and sandboxProblem name their file or the sandbox by label. - seat session: thread store, legacy store, overlap, busy lock and newRun refusals name the place under the data root. - discord: unknown keys, binding names and turn ids are described when not plain; approvals, journal and repository read errors name the file and code; git drops stderr lines that carry a host path and a spawn failure names its code only. - seat, control-board: registration fields and seen keys are described when not plain; invalid seat name and layout state the constraint. - organization: the registry names the config directory and registry file by label; --project is quoted only when it is a plain name; systemConfigProblem moves to its own module. - scripts/mosaic-config.mjs: the config by where it came from, the data root and its marker by role, keys and arguments only when plain, and error codes in place of raw messages. Exit codes unchanged. - webui sso-config: an unknown console key is named only when plain. Sentinel tests in each package's path-redaction-labels file fail on BASE; pins that quoted a path now assert the label and its absence.
587 lines
23 KiB
JavaScript
Executable File
587 lines
23 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Mosaic development configuration: bootstrap, validate, resolve.
|
|
*
|
|
* Operations:
|
|
* bootstrap Create the default config ONLY if absent; otherwise validate
|
|
* the existing file without writing (idempotent).
|
|
* validate Load and strictly validate; print resolved config on stdout.
|
|
* env Print shell-safe exports for launcher scripts:
|
|
* MOSAIC_DATA_ROOT, MOSAIC_PROVIDER, MOSAIC_MODEL.
|
|
* instance Print shell-safe exports naming the selected stack instance
|
|
* (row 190): MOSAIC_INSTANCE, MOSAIC_INSTANCE_CONFIG,
|
|
* MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT and
|
|
* MOSAIC_UNIT_SUFFIX. scripts/instance.sh reads them.
|
|
*
|
|
* Each operation takes an optional `--instance NAME`. NAME matches
|
|
* ^[a-z][a-z0-9]{1,15}$ and selects ~/.config/mosaic-NAME/config.json;
|
|
* `dev` is the default instance, so `--instance dev` is the same as no flag.
|
|
* Without the flag the config is $MOSAIC_CONFIG or
|
|
* ~/.config/mosaic-dev/config.json, as before.
|
|
*
|
|
* A named instance's data root carries <dataRoot>/instance.json, written by
|
|
* `bootstrap --instance NAME`, naming the instance. With the flag every
|
|
* operation refuses a root whose marker is missing or names another
|
|
* instance; `instance` without the flag refuses a root that a named
|
|
* instance claims.
|
|
*
|
|
* Exit codes:
|
|
* 0 success
|
|
* 2 configuration exists but is invalid (never modified by this tool)
|
|
* 3 configuration is missing for a read operation (validate/env/instance),
|
|
* or the data root's instance marker refuses the selection
|
|
* 4 usage: a bad instance name (instance-name-invalid), or --instance and
|
|
* $MOSAIC_CONFIG naming different files (instance-config-mismatch)
|
|
*
|
|
* Invariants (docs/plans/2026-09-02_atomic-mosaic-foundation.md):
|
|
* - Existing configuration is never overwritten.
|
|
* - Validation failure modifies nothing.
|
|
* - Secrets are never stored in configuration.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const SUPPORTED_CONFIG_VERSION = 1;
|
|
const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
|
|
const SUPPORTED_BACKENDS = new Set(["docker"]);
|
|
const SUPPORTED_ADAPTERS = new Set(["pi", "mock"]); // mock: test-only, see adapters/README.md
|
|
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
|
|
// Row 190: stack instance names. No hyphen, so systemd's prefix drop-ins
|
|
// for mosaic-bus-NAME@ stay a fixed list and no name prefixes another's units.
|
|
const INSTANCE_PATTERN = /^[a-z][a-z0-9]{1,15}$/;
|
|
const DEFAULT_INSTANCE = "dev";
|
|
const MARKER = "instance.json";
|
|
|
|
class Failure extends Error {
|
|
constructor(exitCode, message) {
|
|
super(message);
|
|
this.exitCode = exitCode;
|
|
}
|
|
}
|
|
|
|
function fail(exitCode, message) {
|
|
throw new Failure(exitCode, message);
|
|
}
|
|
|
|
// #1873: a refusal names the config by where it came from, the data root and its marker by
|
|
// their roles, and a key or argument only when it is a plain name; never a host path or a raw
|
|
// error message. CONFIG is set when the selection is made.
|
|
let CONFIG = "the default system config";
|
|
function configLabel(instance) {
|
|
return isNamed(instance) ? `the config of instance ${instance}` : process.env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config";
|
|
}
|
|
const plain = (value, otherwise) => /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(String(value)) ? JSON.stringify(String(value)) : otherwise;
|
|
const MARKER_LABEL = "the data root's instance marker";
|
|
|
|
function instancePath(name) {
|
|
return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
|
|
}
|
|
|
|
function realOrNull(file) {
|
|
try {
|
|
return fs.realpathSync(file);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// The config file for a selection. Without --instance, as before. With it,
|
|
// NAME's file; a $MOSAIC_CONFIG beside it must be the same file.
|
|
function configPath(instance) {
|
|
CONFIG = configLabel(instance);
|
|
if (instance === null) {
|
|
return process.env.MOSAIC_CONFIG
|
|
? path.resolve(process.env.MOSAIC_CONFIG)
|
|
: instancePath(DEFAULT_INSTANCE);
|
|
}
|
|
const named = instancePath(instance);
|
|
if (process.env.MOSAIC_CONFIG) {
|
|
const given = path.resolve(process.env.MOSAIC_CONFIG);
|
|
const same = given === named || (realOrNull(given) !== null && realOrNull(given) === realOrNull(named));
|
|
if (!same) {
|
|
fail(4, `instance-config-mismatch: --instance ${instance} names its own config, but MOSAIC_CONFIG names another file`);
|
|
}
|
|
}
|
|
return named;
|
|
}
|
|
|
|
// Operation arguments: nothing, or `--instance NAME` once.
|
|
function parseArgs(args) {
|
|
if (args.length === 0) return null;
|
|
if (args.length !== 2 || args[0] !== "--instance") {
|
|
fail(4, `usage: mosaic-config.mjs ${operation ?? "OP"} [--instance NAME]`);
|
|
}
|
|
const name = args[1];
|
|
if (!INSTANCE_PATTERN.test(name)) {
|
|
fail(4, `instance-name-invalid: ${plain(name, "that name")} must match ${INSTANCE_PATTERN}`);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function isNamed(instance) {
|
|
return instance !== null && instance !== DEFAULT_INSTANCE;
|
|
}
|
|
|
|
// The marker in a data root: null when absent, else its name. Anything else
|
|
// at the path refuses.
|
|
function readMarker(dataRoot) {
|
|
const file = path.join(dataRoot, MARKER);
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(file);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return null;
|
|
fail(3, `instance-root-unclaimed: cannot read ${MARKER_LABEL} (${error.code})`);
|
|
}
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
fail(3, `instance-root-unclaimed: ${MARKER_LABEL} must be a regular, non-symbolic-link file`);
|
|
}
|
|
let marker;
|
|
try {
|
|
marker = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch (error) {
|
|
fail(3, `instance-root-unclaimed: ${MARKER_LABEL} is not a valid marker (${error instanceof SyntaxError ? 'not valid JSON' : `cannot read it, ${error.code ?? "error"}`})`);
|
|
}
|
|
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|
|
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
|
|
fail(3, `instance-root-unclaimed: ${MARKER_LABEL} is not a valid marker ({"instanceVersion":1,"name":NAME})`);
|
|
}
|
|
return marker.name;
|
|
}
|
|
|
|
// The claim check (row 190 D2). A named instance needs its own marker; the
|
|
// default needs none, or one naming dev.
|
|
function checkClaim(dataRoot, instance) {
|
|
const owner = readMarker(dataRoot);
|
|
if (isNamed(instance)) {
|
|
if (owner === null) {
|
|
fail(3, `instance-root-unclaimed: the data root holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
|
|
}
|
|
if (owner !== instance) {
|
|
fail(3, `instance-root-mismatch: the data root is claimed by instance ${owner}, not ${instance}`);
|
|
}
|
|
} else if (owner !== null && owner !== DEFAULT_INSTANCE) {
|
|
fail(3, `instance-root-mismatch: the data root is claimed by instance ${owner}; select it with --instance ${owner}`);
|
|
}
|
|
}
|
|
|
|
function readRaw(file) {
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(file);
|
|
} catch {
|
|
return null; // missing
|
|
}
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
fail(2, `configuration path must be a regular, non-symbolic-link file: ${CONFIG}`);
|
|
}
|
|
try {
|
|
return fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
fail(2, `configuration file is not readable: ${CONFIG}`);
|
|
}
|
|
}
|
|
|
|
function rejectUnknownKeys(object, allowed, where) {
|
|
for (const key of Object.keys(object)) {
|
|
if (!allowed.includes(key)) {
|
|
fail(2, `unsupported ${where} key: ${plain(key, "that is not a plain name")}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function validateDataRoot(value, file) {
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
fail(2, 'execution "dataRoot" must be a non-empty string');
|
|
}
|
|
if (value.includes("\0")) {
|
|
fail(2, 'execution "dataRoot" contains a NUL byte');
|
|
}
|
|
if (!path.isAbsolute(value)) {
|
|
fail(2, `execution "dataRoot" must be an absolute path`);
|
|
}
|
|
const resolved = path.resolve(value);
|
|
if (resolved !== value) {
|
|
fail(2, `execution "dataRoot" must be canonical without ".", "..", trailing slashes, or redundant separators`);
|
|
}
|
|
if (resolved === path.parse(resolved).root) {
|
|
fail(2, 'execution "dataRoot" must not be the filesystem root');
|
|
}
|
|
const home = path.resolve(os.homedir());
|
|
if (resolved === home || isAncestorOf(resolved, home)) {
|
|
fail(2, 'execution "dataRoot" must not be or contain the home directory');
|
|
}
|
|
const configDir = path.dirname(path.resolve(file));
|
|
if (resolved === configDir || isAncestorOf(resolved, configDir)) {
|
|
fail(2, 'execution "dataRoot" must not be or contain the configuration directory');
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
function isAncestorOf(ancestor, candidate) {
|
|
const rel = path.relative(ancestor, candidate);
|
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
}
|
|
|
|
// Q175 phase 1 (#1690): the optional `console` key, checked for shape only by the Console's own
|
|
// checker. It is imported only when the key is there, so a config without it never needs the
|
|
// Console's code, and a copy of this file alone still reads one.
|
|
async function validateConsole(value) {
|
|
let checkConsole;
|
|
try {
|
|
({ checkConsole } = await import(pathToFileURL(path.join(import.meta.dirname, "..", "packages", "webui", "src", "sso-config.mjs")).href));
|
|
} catch {
|
|
fail(2, 'the "console" key needs packages/webui/src/sso-config.mjs beside this loader');
|
|
}
|
|
try {
|
|
return checkConsole(value);
|
|
} catch (error) {
|
|
fail(2, error.message);
|
|
}
|
|
}
|
|
|
|
// FED R2 (#1701, DESIGN.md section 3.1 D1): the optional `federation` key names this instance and its
|
|
// peers. Ids and origins are labels, not trust: nothing here grants a peer anything (3.1, W1). The
|
|
// origin must be exactly an https origin, the form `new URL(origin).origin` returns, so a path, a
|
|
// trailing slash, a port that is the default, or userinfo refuses.
|
|
const FEDERATION_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
const FEDERATION_LABEL_MAX = 80;
|
|
|
|
function validateFederationOrigin(value, where) {
|
|
let url = null;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
url = null;
|
|
}
|
|
if (url === null || url.protocol !== "https:" || url.origin !== value) {
|
|
fail(2, `${where} must be an https origin with no path, query, or userinfo`);
|
|
}
|
|
}
|
|
|
|
function validateFederation(value) {
|
|
if (!isPlainObject(value)) {
|
|
fail(2, '"federation" must be a JSON object');
|
|
}
|
|
rejectUnknownKeys(value, ["self", "peers"], "federation");
|
|
if (typeof value.self !== "string" || !FEDERATION_ID_PATTERN.test(value.self)) {
|
|
fail(2, `federation.self must match ${FEDERATION_ID_PATTERN}`);
|
|
}
|
|
if (!Array.isArray(value.peers)) {
|
|
fail(2, 'federation.peers must be an array');
|
|
}
|
|
const ids = new Set([value.self]);
|
|
const peers = value.peers.map((peer, index) => {
|
|
const where = `federation.peers[${index}]`;
|
|
if (!isPlainObject(peer)) {
|
|
fail(2, `${where} must be a JSON object`);
|
|
}
|
|
rejectUnknownKeys(peer, ["id", "origin", "label"], where);
|
|
if (typeof peer.id !== "string" || !FEDERATION_ID_PATTERN.test(peer.id)) {
|
|
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN}`);
|
|
}
|
|
if (ids.has(peer.id)) {
|
|
fail(2, `${where}.id ${JSON.stringify(peer.id)} is already named (by federation.self or an earlier peer)`);
|
|
}
|
|
ids.add(peer.id);
|
|
validateFederationOrigin(peer.origin, `${where}.origin`);
|
|
if (typeof peer.label !== "string" || peer.label.trim() === "" || peer.label.length > FEDERATION_LABEL_MAX) {
|
|
fail(2, `${where}.label must be a non-empty string of at most ${FEDERATION_LABEL_MAX} characters`);
|
|
}
|
|
return { id: peer.id, origin: peer.origin, label: peer.label };
|
|
});
|
|
return { self: value.self, peers };
|
|
}
|
|
|
|
async function validate(document, file) {
|
|
if (!isPlainObject(document)) {
|
|
fail(2, "configuration must be a JSON object");
|
|
}
|
|
// Row 270 r2 and r3 (mos-1352, DECISIONS Q222, sage-3038): compactionReadBytes and
|
|
// compactionStreams, optional and human-only, pass on as written; the launcher takes the
|
|
// default for a missing or bad value (packages/seat/src/compactions.mjs readBudget and
|
|
// readStreams). The first list stays as it was:
|
|
// packages/cli/tests/stack-release-fixture.mjs edits it to make an older loader.
|
|
rejectUnknownKeys(
|
|
document,
|
|
["configVersion", "environment", "dataRoot", "execution", "console", "federation"].concat(["compactionReadBytes", "compactionStreams"]),
|
|
"configuration",
|
|
);
|
|
|
|
if (document.configVersion !== SUPPORTED_CONFIG_VERSION) {
|
|
fail(2, `unsupported configVersion: (supported: ${SUPPORTED_CONFIG_VERSION})`);
|
|
}
|
|
if (!SUPPORTED_ENVIRONMENTS.has(document.environment)) {
|
|
fail(2, `unsupported environment: (supported: ${[...SUPPORTED_ENVIRONMENTS].join(", ")})`);
|
|
}
|
|
|
|
const dataRoot = validateDataRoot(document.dataRoot, file);
|
|
|
|
if (!isPlainObject(document.execution)) {
|
|
fail(2, '"execution" must be a JSON object');
|
|
}
|
|
rejectUnknownKeys(document.execution, ["backend", "provider", "model", "adapter"], '"execution"');
|
|
if (!SUPPORTED_BACKENDS.has(document.execution.backend)) {
|
|
fail(2, `unsupported execution.backend: (supported: ${[...SUPPORTED_BACKENDS].join(", ")})`);
|
|
}
|
|
for (const key of ["provider", "model"]) {
|
|
const value = document.execution[key];
|
|
if (typeof value !== "string" || !NAME_PATTERN.test(value)) {
|
|
fail(2, `execution.${key} must match ${NAME_PATTERN}`);
|
|
}
|
|
}
|
|
|
|
const adapter = document.execution.adapter === undefined || document.execution.adapter === null
|
|
? "pi"
|
|
: document.execution.adapter;
|
|
if (typeof adapter !== "string" || !SUPPORTED_ADAPTERS.has(adapter)) {
|
|
fail(2, `unsupported execution.adapter: (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
|
|
}
|
|
|
|
// Last, so the older checks keep their order. The key is in the output only when the file has
|
|
// it, so a config without it prints what it always printed.
|
|
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
|
|
|
|
// FED R2: the same rule as `console`. The key appears in the output only when the file has it.
|
|
const federationKey = Object.hasOwn(document, "federation") ? validateFederation(document.federation) : undefined;
|
|
|
|
let proxy;
|
|
try {
|
|
let present = false;
|
|
try { fs.lstatSync(path.join(path.dirname(file), "proxy.json")); present = true; }
|
|
catch (e) { if (e.code !== "ENOENT") throw e; }
|
|
// Historical no-proxy copies have no model-proxy package. Do not load a new
|
|
// dependency unless the sidecar exists; a broken symlink is present and refuses.
|
|
if (present) {
|
|
const { loadProxy } = await import("../packages/model-proxy/src/settings.mjs");
|
|
proxy = loadProxy(path.dirname(file), dataRoot);
|
|
}
|
|
} catch (error) { fail(2, ['proxy-rotate-grace-invalid', 'proxy-first-output-invalid'].includes(error.code) ? error.code : "proxy-config-invalid"); }
|
|
|
|
return {
|
|
configVersion: document.configVersion,
|
|
...(proxy === undefined ? {} : { proxy }),
|
|
environment: document.environment,
|
|
dataRoot,
|
|
execution: {
|
|
backend: document.execution.backend,
|
|
provider: document.execution.provider,
|
|
model: document.execution.model,
|
|
adapter,
|
|
},
|
|
...(consoleKey === undefined ? {} : { console: consoleKey }),
|
|
...(federationKey === undefined ? {} : { federation: federationKey }),
|
|
...(Object.hasOwn(document, "compactionReadBytes") ? { compactionReadBytes: document.compactionReadBytes } : {}),
|
|
...(Object.hasOwn(document, "compactionStreams") ? { compactionStreams: document.compactionStreams } : {}),
|
|
};
|
|
}
|
|
|
|
async function load(file, instance = null) {
|
|
const raw = readRaw(file);
|
|
if (raw === null) {
|
|
const bootstrap = isNamed(instance) ? `scripts/bootstrap.sh --instance ${instance}` : "scripts/bootstrap.sh";
|
|
// #1873: named by where it came from, never by host path.
|
|
fail(3, `configuration not found: ${configLabel(instance)} (run ${bootstrap} to create it)`);
|
|
}
|
|
let document;
|
|
try {
|
|
document = JSON.parse(raw);
|
|
} catch {
|
|
fail(2, `configuration is not valid JSON (${CONFIG})`);
|
|
}
|
|
return await validate(document, file);
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
|
}
|
|
|
|
function defaultConfig(dataRoot) {
|
|
return {
|
|
configVersion: 1,
|
|
environment: "development",
|
|
dataRoot,
|
|
execution: {
|
|
backend: "docker",
|
|
provider: "zai",
|
|
model: "glm-5.3-flash",
|
|
},
|
|
};
|
|
}
|
|
|
|
const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
|
|
|
|
// 'wx': creation is exclusive; an existing file is never overwritten, and a
|
|
// symbolic link at the path is never followed.
|
|
function createExclusive(file, text, label) {
|
|
let fd;
|
|
try {
|
|
fd = fs.openSync(file, "wx", 0o644);
|
|
fs.writeFileSync(fd, text);
|
|
return true;
|
|
} catch (error) {
|
|
if (error.code === "EEXIST") return false;
|
|
fail(1, `unable to create ${label} (${error.code ?? "error"})`);
|
|
} finally {
|
|
if (fd !== undefined) fs.closeSync(fd);
|
|
}
|
|
}
|
|
|
|
async function bootstrapDefault(file) {
|
|
if (fs.existsSync(file)) {
|
|
await load(file); // validate only; never rewrite
|
|
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
|
|
return;
|
|
}
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
if (!createExclusive(file, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "configuration")) {
|
|
await load(file);
|
|
return;
|
|
}
|
|
await load(file);
|
|
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
|
|
}
|
|
|
|
// Row 190 D2: NAME's config dir (0700), its config, then the claimed data
|
|
// root. The root is checked before anything is written, so a refusal
|
|
// writes nothing.
|
|
async function bootstrapNamed(file, instance) {
|
|
if (fs.existsSync(file)) {
|
|
const resolved = await load(file, instance); // validate only; never rewrite
|
|
checkClaim(resolved.dataRoot, instance);
|
|
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
|
|
return;
|
|
}
|
|
const dataRoot = path.join(os.homedir(), `.mosaic-${instance}`);
|
|
let entries = null;
|
|
try {
|
|
const stat = fs.lstatSync(dataRoot);
|
|
if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: the instance's data root exists and is not a directory; nothing was written`);
|
|
entries = fs.readdirSync(dataRoot);
|
|
} catch (error) {
|
|
if (error instanceof Failure) throw error;
|
|
if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read the instance's data root (${error.code}); nothing was written`);
|
|
}
|
|
if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) {
|
|
fail(3, `instance-root-unclaimed: the instance's data root exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`);
|
|
}
|
|
const configDir = path.dirname(file);
|
|
fs.mkdirSync(path.dirname(configDir), { recursive: true });
|
|
try {
|
|
fs.mkdirSync(configDir, { mode: 0o700 });
|
|
} catch (error) {
|
|
if (error.code !== "EEXIST") fail(1, `unable to create the config directory of instance ${instance} (${error.code ?? "error"})`);
|
|
}
|
|
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`, CONFIG)) {
|
|
const resolved = await load(file, instance);
|
|
checkClaim(resolved.dataRoot, instance);
|
|
return;
|
|
}
|
|
const resolved = await load(file, instance);
|
|
try {
|
|
fs.mkdirSync(resolved.dataRoot, { mode: 0o700 });
|
|
} catch (error) {
|
|
if (error.code !== "EEXIST") fail(1, `unable to create the data root (${error.code ?? "error"})`);
|
|
}
|
|
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`, MARKER_LABEL);
|
|
checkClaim(resolved.dataRoot, instance);
|
|
process.stderr.write(`mosaic-config: created configuration for instance ${instance}: ${file}, data root ${resolved.dataRoot}\n`);
|
|
}
|
|
|
|
// Without --instance, a missing or invalid config is left to the caller's
|
|
// own load, as before; only a loaded config's root is checked.
|
|
async function selected(instance) {
|
|
const file = configPath(instance);
|
|
let dataRoot = "";
|
|
let proxy = false;
|
|
if (isNamed(instance)) {
|
|
const resolved = await load(file, instance);
|
|
dataRoot = resolved.dataRoot;
|
|
proxy = Boolean(resolved.proxy);
|
|
checkClaim(dataRoot, instance);
|
|
} else {
|
|
let resolved = null;
|
|
try {
|
|
resolved = readRaw(file) === null ? null : await load(file);
|
|
} catch (error) {
|
|
if (!(error instanceof Failure) || error.message === "proxy-config-invalid") throw error;
|
|
}
|
|
if (resolved !== null) {
|
|
dataRoot = resolved.dataRoot;
|
|
proxy = Boolean(resolved.proxy);
|
|
checkClaim(dataRoot, instance);
|
|
}
|
|
}
|
|
const name = isNamed(instance) ? instance : DEFAULT_INSTANCE;
|
|
return [
|
|
`MOSAIC_INSTANCE=${shellQuote(name)}`,
|
|
`MOSAIC_INSTANCE_CONFIG=${shellQuote(path.resolve(file))}`,
|
|
`MOSAIC_INSTANCE_CONFIG_DIR=${shellQuote(path.dirname(path.resolve(file)))}`,
|
|
`MOSAIC_INSTANCE_DATA_ROOT=${shellQuote(dataRoot)}`,
|
|
`MOSAIC_UNIT_SUFFIX=${shellQuote(isNamed(instance) ? `-${instance}` : "")}`,
|
|
...(proxy ? ["MOSAIC_INSTANCE_PROXY_CONFIGURED=1"] : []),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
async function loadSelected(instance) {
|
|
const file = configPath(instance);
|
|
const resolved = await load(file, instance);
|
|
if (isNamed(instance)) checkClaim(resolved.dataRoot, instance);
|
|
return resolved;
|
|
}
|
|
|
|
const operation = process.argv[2];
|
|
|
|
try {
|
|
if (!["bootstrap", "validate", "env", "instance"].includes(operation)) {
|
|
fail(1, `unknown operation: ${plain(operation ?? "", "that is not one")} (expected bootstrap | validate | env | instance)`);
|
|
}
|
|
const instance = parseArgs(process.argv.slice(3));
|
|
switch (operation) {
|
|
case "bootstrap": {
|
|
const file = configPath(instance);
|
|
if (isNamed(instance)) await bootstrapNamed(file, instance);
|
|
else await bootstrapDefault(file);
|
|
process.exit(0);
|
|
}
|
|
|
|
case "validate": {
|
|
const resolved = await loadSelected(instance);
|
|
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
case "env": {
|
|
const resolved = await loadSelected(instance);
|
|
process.stdout.write(
|
|
[
|
|
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
|
|
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
|
|
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
|
|
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
case "instance": {
|
|
process.stdout.write(await selected(instance));
|
|
process.exit(0);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (!(error instanceof Failure)) throw error;
|
|
process.stderr.write(`mosaic-config: ${error.message}\n`);
|
|
process.exit(error.exitCode);
|
|
}
|