Source-candidate: poe d9a564e6d20f5f0fd75ce45b12cbbf8bb4c9d568\nSource-candidate: magica38ba5762b06716db1dee00d0afec162656488f5c\nValidated321whole-file phase plus whole bus1349/model-proxy100/webui490; final gate tooling checks pending, not landed.
575 lines
22 KiB
JavaScript
Executable File
575 lines
22 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);
|
|
}
|
|
|
|
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) {
|
|
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 ${named}, but MOSAIC_CONFIG names ${given}`);
|
|
}
|
|
}
|
|
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: ${JSON.stringify(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 ${file} (${error.code})`);
|
|
}
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
fail(3, `instance-root-unclaimed: ${file} 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: ${file} is not a valid marker (${error.message})`);
|
|
}
|
|
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: ${file} 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: ${dataRoot} holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
|
|
}
|
|
if (owner !== instance) {
|
|
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}, not ${instance}`);
|
|
}
|
|
} else if (owner !== null && owner !== DEFAULT_INSTANCE) {
|
|
fail(3, `instance-root-mismatch: ${dataRoot} 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: ${file}`);
|
|
}
|
|
try {
|
|
return fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
fail(2, `configuration file is not readable: ${file}`);
|
|
}
|
|
}
|
|
|
|
function rejectUnknownKeys(object, allowed, where) {
|
|
for (const key of Object.keys(object)) {
|
|
if (!allowed.includes(key)) {
|
|
fail(2, `unsupported ${where} key: "${key}"`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (got "${value}")`);
|
|
}
|
|
const resolved = path.resolve(value);
|
|
if (resolved !== value) {
|
|
fail(2, `execution "dataRoot" must be canonical without ".", "..", trailing slashes, or redundant separators (got "${value}")`);
|
|
}
|
|
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 (${home})`);
|
|
}
|
|
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 (${configDir})`);
|
|
}
|
|
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 (got ${JSON.stringify(value)})`);
|
|
}
|
|
}
|
|
|
|
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} (got ${JSON.stringify(value.self)})`);
|
|
}
|
|
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} (got ${JSON.stringify(peer.id)})`);
|
|
}
|
|
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: ${JSON.stringify(document.configVersion)} (supported: ${SUPPORTED_CONFIG_VERSION})`);
|
|
}
|
|
if (!SUPPORTED_ENVIRONMENTS.has(document.environment)) {
|
|
fail(2, `unsupported environment: ${JSON.stringify(document.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: ${JSON.stringify(document.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} (got ${JSON.stringify(value)})`);
|
|
}
|
|
}
|
|
|
|
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: ${JSON.stringify(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";
|
|
fail(3, `configuration not found: ${file} (run ${bootstrap} to create it)`);
|
|
}
|
|
let document;
|
|
try {
|
|
document = JSON.parse(raw);
|
|
} catch (error) {
|
|
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
|
|
}
|
|
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 = file) {
|
|
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.message}`);
|
|
} 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: ${dataRoot} 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 ${dataRoot} (${error.code}); nothing was written`);
|
|
}
|
|
if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) {
|
|
fail(3, `instance-root-unclaimed: ${dataRoot} 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 ${configDir}: ${error.message}`);
|
|
}
|
|
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`)) {
|
|
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 ${resolved.dataRoot}: ${error.message}`);
|
|
}
|
|
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`);
|
|
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: ${JSON.stringify(operation ?? "")} (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);
|
|
}
|