@plexus-ms/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/plx.js +497 -150
  2. package/package.json +3 -3
package/dist/plx.js CHANGED
@@ -7268,7 +7268,7 @@ var require_public_api = __commonJS({
7268
7268
  }
7269
7269
  return doc;
7270
7270
  }
7271
- function parse4(src, reviver, options) {
7271
+ function parse5(src, reviver, options) {
7272
7272
  let _reviver = void 0;
7273
7273
  if (typeof reviver === "function") {
7274
7274
  _reviver = reviver;
@@ -7309,7 +7309,7 @@ var require_public_api = __commonJS({
7309
7309
  return value.toString(options);
7310
7310
  return new Document.Document(value, _replacer, options).toString(options);
7311
7311
  }
7312
- exports.parse = parse4;
7312
+ exports.parse = parse5;
7313
7313
  exports.parseAllDocuments = parseAllDocuments;
7314
7314
  exports.parseDocument = parseDocument;
7315
7315
  exports.stringify = stringify;
@@ -7655,9 +7655,83 @@ async function readCatalog(ctx, compendium) {
7655
7655
  return catalog;
7656
7656
  }
7657
7657
 
7658
+ // ../lib/dist/changedApps.js
7659
+ import { join as join2 } from "node:path";
7660
+ var BOOKKEEPING = /(^|\/)(PLEXUS\.md|\.copier-answers\.yml)$/;
7661
+ var GLOBALS = /^(pnpm-lock\.yaml|pnpm-workspace\.yaml|package\.json|turbo\.json|mise\.toml|\.npmrc|\.node-version)$/;
7662
+ var NO_REF = /^0*$/;
7663
+ async function selectChangedApps(input) {
7664
+ const all = [...input.all].sort();
7665
+ if (input.changed === void 0)
7666
+ return { apps: all, reason: "no base ref: every app counts as changed" };
7667
+ const changed = input.changed.filter((f) => !BOOKKEEPING.test(f));
7668
+ const under = (app) => changed.some((f) => f.startsWith(`apps/${app}/`));
7669
+ if (!input.workspace)
7670
+ return { apps: all.filter(under), reason: "files under the apps changed" };
7671
+ if (changed.length === 0)
7672
+ return { apps: [], reason: "nothing but bookkeeping changed" };
7673
+ if (changed.some((f) => GLOBALS.test(f)))
7674
+ return { apps: all, reason: "a workspace-global input changed: every app counts as changed" };
7675
+ const outside = changed.some((f) => !f.startsWith("apps/"));
7676
+ const listed = new Set(await input.dependents?.() ?? []);
7677
+ return {
7678
+ apps: all.filter((app) => listed.has(app) && (outside || under(app))),
7679
+ reason: "sources or their dependencies changed"
7680
+ };
7681
+ }
7682
+ async function readChangedApps(ctx, dir, from, to = "HEAD") {
7683
+ const git2 = (args) => ctx.read("git", args, { cwd: dir });
7684
+ const workspace = await exists(ctx, join2(dir, "pnpm-workspace.yaml"));
7685
+ const all = await deployableApps(ctx, dir, workspace);
7686
+ let changed;
7687
+ if (from !== "" && !NO_REF.test(from) && await isCommit(git2, from))
7688
+ changed = (await git2(["diff", "--name-only", from, to])).split("\n").filter(Boolean);
7689
+ const dependents = async () => {
7690
+ const out = await ctx.read("pnpm", ["--filter", `...[${from}...${to}]`, "ls", "--depth", "-1", "--parseable"], {
7691
+ cwd: dir
7692
+ });
7693
+ return out.split("\n").map((line) => /(?:^|\/)apps\/([^/]+)$/.exec(line.trim())?.[1]).filter((app) => app !== void 0);
7694
+ };
7695
+ const result = await selectChangedApps({ workspace, all, changed, dependents });
7696
+ ctx.say(`\u2192 ${result.reason}`, 1);
7697
+ return result;
7698
+ }
7699
+ async function isCommit(git2, ref) {
7700
+ try {
7701
+ await git2(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
7702
+ return true;
7703
+ } catch {
7704
+ return false;
7705
+ }
7706
+ }
7707
+ async function deployableApps(ctx, dir, workspace) {
7708
+ const entries = await ctx.readDir(join2(dir, "apps")).catch(() => []);
7709
+ const apps = [];
7710
+ for (const e of entries) {
7711
+ if (!e.directory)
7712
+ continue;
7713
+ const marks = workspace ? ["package.json"] : ["docker/compose.yaml", "compose.yaml"];
7714
+ for (const mark of marks) {
7715
+ if (await exists(ctx, join2(dir, "apps", e.name, mark))) {
7716
+ apps.push(e.name);
7717
+ break;
7718
+ }
7719
+ }
7720
+ }
7721
+ return apps.sort();
7722
+ }
7723
+ async function exists(ctx, path3) {
7724
+ try {
7725
+ await ctx.readFile(path3);
7726
+ return true;
7727
+ } catch {
7728
+ return false;
7729
+ }
7730
+ }
7731
+
7658
7732
  // ../lib/dist/changeset.js
7659
7733
  var import_yaml2 = __toESM(require_dist(), 1);
7660
- import { join as join2 } from "node:path";
7734
+ import { join as join3 } from "node:path";
7661
7735
 
7662
7736
  // ../lib/dist/io.js
7663
7737
  import { spawn } from "node:child_process";
@@ -7680,7 +7754,7 @@ function realIo(prompt) {
7680
7754
  stdout: process.stdout,
7681
7755
  stderr: process.stderr,
7682
7756
  prompt: prompt && isInteractive() ? prompt : void 0,
7683
- exec: (command, args, options = {}) => new Promise((resolve7, reject) => {
7757
+ exec: (command, args, options = {}) => new Promise((resolve10, reject) => {
7684
7758
  const child = spawn(command, args, {
7685
7759
  cwd: options.cwd,
7686
7760
  env: options.env,
@@ -7703,7 +7777,7 @@ function realIo(prompt) {
7703
7777
  child.on("error", reject);
7704
7778
  child.on("close", (code) => {
7705
7779
  if (code === 0)
7706
- resolve7(stdout);
7780
+ resolve10(stdout);
7707
7781
  else
7708
7782
  reject(new ExecError(render(command, args), code, stderr));
7709
7783
  });
@@ -7734,8 +7808,6 @@ function describe(step) {
7734
7808
  switch (step.kind) {
7735
7809
  case "run":
7736
7810
  return `$ ${step.cwd ? `(cd ${step.cwd} && ` : ""}${render(step.command, step.args)}${step.cwd ? ")" : ""}`;
7737
- case "ssh":
7738
- return `$ ${render("ssh", [step.host, "bash", "-s"])} <<'EOF'`;
7739
7811
  case "write":
7740
7812
  return `> ${step.path} (${Buffer.byteLength(step.content)} bytes)`;
7741
7813
  }
@@ -7744,9 +7816,6 @@ function body(step) {
7744
7816
  switch (step.kind) {
7745
7817
  case "run":
7746
7818
  return step.input;
7747
- case "ssh":
7748
- return `${step.script.trimEnd()}
7749
- EOF`;
7750
7819
  case "write":
7751
7820
  return step.content;
7752
7821
  }
@@ -7794,7 +7863,7 @@ ${header.join("\n")}
7794
7863
 
7795
7864
  ${lines.join("\n")}
7796
7865
  `;
7797
- return { path: path3, releases, changes, content, plan: [write(root ? join2(root, path3) : path3, content)] };
7866
+ return { path: path3, releases, changes, content, plan: [write(root ? join3(root, path3) : path3, content)] };
7798
7867
  }
7799
7868
  function describeChange(c) {
7800
7869
  const peer = c.field === "peerDependencies" ? " (peer)" : "";
@@ -7843,7 +7912,7 @@ async function readChangeset(ctx, dir, base, branch) {
7843
7912
  };
7844
7913
  const current = async (path4) => {
7845
7914
  try {
7846
- return await ctx.readFile(join2(dir, path4));
7915
+ return await ctx.readFile(join3(dir, path4));
7847
7916
  } catch {
7848
7917
  return void 0;
7849
7918
  }
@@ -7859,7 +7928,7 @@ async function readChangeset(ctx, dir, base, branch) {
7859
7928
  }
7860
7929
  const config = await current(`${CHANGESET_DIR}/config.json`);
7861
7930
  const ignore = config ? JSON.parse(config).ignore ?? [] : [];
7862
- const entries = await ctx.readDir(join2(dir, CHANGESET_DIR)).catch(() => []);
7931
+ const entries = await ctx.readDir(join3(dir, CHANGESET_DIR)).catch(() => []);
7863
7932
  const path3 = changesetPath(head);
7864
7933
  return planChangeset({
7865
7934
  branch: head,
@@ -7879,7 +7948,7 @@ async function expand(ctx, root, glob) {
7879
7948
  if (star < 0)
7880
7949
  return [glob.replace(/\/$/, "")];
7881
7950
  const parent = glob.slice(0, star).replace(/\/$/, "");
7882
- const entries = await ctx.readDir(join2(root, parent)).catch(() => []);
7951
+ const entries = await ctx.readDir(join3(root, parent)).catch(() => []);
7883
7952
  return entries.filter((e) => e.directory && !e.name.startsWith(".")).map((e) => parent ? `${parent}/${e.name}` : e.name).sort();
7884
7953
  }
7885
7954
 
@@ -7975,6 +8044,8 @@ var Context = class {
7975
8044
  */
7976
8045
  result(data, human = renderHuman) {
7977
8046
  const text = this.json ? JSON.stringify(data, null, 2) : human(data);
8047
+ if (text === "")
8048
+ return;
7978
8049
  this.io.stdout.write(text.endsWith("\n") ? text : `${text}
7979
8050
  `);
7980
8051
  }
@@ -8034,8 +8105,6 @@ var Context = class {
8034
8105
  switch (step.kind) {
8035
8106
  case "run":
8036
8107
  return this.io.exec(step.command, step.args, { cwd: step.cwd, input: step.input, inherit });
8037
- case "ssh":
8038
- return this.io.exec("ssh", [step.host, "bash", "-s"], { input: step.script, inherit });
8039
8108
  case "write":
8040
8109
  await this.io.writeFile(step.path, step.content);
8041
8110
  return void 0;
@@ -8069,6 +8138,14 @@ function parseAnswers(text) {
8069
8138
  };
8070
8139
  }
8071
8140
 
8141
+ // ../lib/dist/deploy.js
8142
+ import { randomBytes } from "node:crypto";
8143
+ import { tmpdir } from "node:os";
8144
+ import { join as join5 } from "node:path";
8145
+
8146
+ // ../lib/dist/train.js
8147
+ import { join as join4 } from "node:path";
8148
+
8072
8149
  // ../lib/dist/doctor.js
8073
8150
  import { relative as relative2 } from "node:path";
8074
8151
  function diagnose(root, catalog) {
@@ -8128,6 +8205,297 @@ function compareTrains(a, b) {
8128
8205
  return 0;
8129
8206
  }
8130
8207
 
8208
+ // ../lib/dist/train.js
8209
+ var TRAIN_REPOS = [
8210
+ "platform",
8211
+ "ci-cd",
8212
+ "preset-repo-web",
8213
+ "preset-app-nextjs",
8214
+ "preset-repo-platform",
8215
+ "compendium"
8216
+ ];
8217
+ var UPSTREAM = "plexus-ms";
8218
+ var isTrainVersion = (v) => /^\d+\.\d+\.\d+$/.test(v);
8219
+ function nextMinor(version2) {
8220
+ const [major = "0", minor = "0"] = version2.split(".");
8221
+ return `${major}.${Number(minor) + 1}.0`;
8222
+ }
8223
+ function latestTag(tags) {
8224
+ return tags.filter((t) => /^v?\d/.test(t)).sort((a, b) => compareTrains(a.replace(/^v/, ""), b.replace(/^v/, ""))).at(-1);
8225
+ }
8226
+ async function readTrain(ctx, upstream) {
8227
+ const repos = [];
8228
+ const all = /* @__PURE__ */ new Set();
8229
+ for (const name of TRAIN_REPOS) {
8230
+ const path3 = join4(upstream, name);
8231
+ try {
8232
+ await ctx.read("git", ["rev-parse", "--git-dir"], { cwd: path3 });
8233
+ } catch {
8234
+ throw new Error(`${path3} is not a git repo`);
8235
+ }
8236
+ try {
8237
+ await ctx.read("git", ["fetch", "origin", "--tags", "--quiet"], { cwd: path3 });
8238
+ } catch {
8239
+ throw new Error(`${name}: fetch from origin failed`);
8240
+ }
8241
+ const tags = (await ctx.read("git", ["tag", "--list", "v*", "[0-9]*"], { cwd: path3 })).split("\n").filter(Boolean);
8242
+ for (const t of tags)
8243
+ all.add(t.replace(/^v/, ""));
8244
+ const tag = latestTag(tags);
8245
+ const range = tag ? `${tag}..HEAD` : "HEAD";
8246
+ const pending = (await ctx.read("git", ["log", "--oneline", range], { cwd: path3 })).split("\n").filter(Boolean);
8247
+ repos.push({ name, path: path3, tag, latest: tag?.replace(/^v/, ""), pending });
8248
+ }
8249
+ const latest = repos.map((r) => r.latest);
8250
+ return { repos, last: latestTag([...all]), skew: latest.some((l) => l !== latest[0]) };
8251
+ }
8252
+ async function preflightRelease(ctx, train) {
8253
+ for (const { name, path: path3 } of train.repos) {
8254
+ const branch = (await ctx.read("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path3 })).trim();
8255
+ if (branch !== "main")
8256
+ throw new Error(`${name} is on '${branch}', trains cut from main`);
8257
+ if ((await ctx.read("git", ["status", "--porcelain"], { cwd: path3 })).trim() !== "")
8258
+ throw new Error(`${name} has uncommitted changes \u2014 commit first`);
8259
+ const behind = (await ctx.read("git", ["rev-list", "--count", "main..origin/main"], { cwd: path3 })).trim();
8260
+ if (behind !== "0")
8261
+ throw new Error(`${name} is ${behind} commit(s) behind origin/main \u2014 pull first, or the tag lands on a stale commit`);
8262
+ }
8263
+ }
8264
+ function resolveVersion(train, explicit, propagateOnly) {
8265
+ if (explicit !== void 0 && !isTrainVersion(explicit))
8266
+ throw new UsageError(`'${explicit}' is not plain semver (X.Y.Z, no v)`);
8267
+ if (propagateOnly) {
8268
+ const v = explicit ?? train.last;
8269
+ if (!v)
8270
+ throw new Error("no train version found to propagate");
8271
+ return v;
8272
+ }
8273
+ if (explicit)
8274
+ return explicit;
8275
+ if (train.skew) {
8276
+ const report = train.repos.map((r) => ` ${r.name}: ${r.latest ?? "none"}`).join("\n");
8277
+ throw new Error(`the train repos' latest tags disagree:
8278
+ ${report}
8279
+ pass the version explicitly (--train ${train.last}) to finish the half-tagged train, or a higher one to start a new one`);
8280
+ }
8281
+ return train.last ? nextMinor(train.last) : "0.1.0";
8282
+ }
8283
+ var UsageError = class extends Error {
8284
+ };
8285
+
8286
+ // ../lib/dist/deploy.js
8287
+ var PLACEHOLDER = /\$\{PLEXUS_DEPLOYMENT_IMAGE(:[^}]*)?\}/g;
8288
+ var INCOMING = ".incoming";
8289
+ function renderCompose(text, image, path3) {
8290
+ const has = PLACEHOLDER.test(text);
8291
+ PLACEHOLDER.lastIndex = 0;
8292
+ if (image) {
8293
+ if (!has)
8294
+ throw new UsageError(`${path3} does not reference \${PLEXUS_DEPLOYMENT_IMAGE}; an image ref was given but nothing would use it (\xA7 3 PLX-App)`);
8295
+ return text.replace(PLACEHOLDER, image);
8296
+ }
8297
+ if (has)
8298
+ throw new UsageError(`${path3} references \${PLEXUS_DEPLOYMENT_IMAGE} but no image ref was given`);
8299
+ return text;
8300
+ }
8301
+ var q = (word) => `'${word.replaceAll("'", "'\\''")}'`;
8302
+ var Remote = class {
8303
+ socket = join5(tmpdir(), `plx-deploy-${randomBytes(4).toString("hex")}.sock`);
8304
+ ctx;
8305
+ host;
8306
+ constructor(ctx, host) {
8307
+ this.ctx = ctx;
8308
+ this.host = host;
8309
+ }
8310
+ args(command) {
8311
+ return [
8312
+ "-o",
8313
+ "ControlMaster=auto",
8314
+ "-o",
8315
+ `ControlPath=${this.socket}`,
8316
+ "-o",
8317
+ "ControlPersist=60",
8318
+ "-o",
8319
+ "StrictHostKeyChecking=accept-new",
8320
+ this.host,
8321
+ "--",
8322
+ command
8323
+ ];
8324
+ }
8325
+ /** An effect on the host: narrated, performed unless --dry-run. */
8326
+ exec(command, input) {
8327
+ return this.ctx.apply([run("ssh", this.args(command), input === void 0 ? {} : { input })]);
8328
+ }
8329
+ /**
8330
+ * A read from the host: its output, or undefined under --dry-run, where no
8331
+ * host is reached. A read that is a step of the procedure (`show`) is
8332
+ * printed by the dry run like an effect, so the operator copies it too.
8333
+ */
8334
+ async read(command, show = false) {
8335
+ if (this.ctx.dryRun) {
8336
+ this.ctx.say(`${show ? "[dry-run] $" : "?"} ssh ${this.host} -- ${q(command)}`, show ? 0 : 1);
8337
+ return void 0;
8338
+ }
8339
+ return (await this.ctx.read("ssh", this.args(command))).trimEnd();
8340
+ }
8341
+ /** A check on the host: whether the command succeeds, or undefined under --dry-run. */
8342
+ async test(command, show = false) {
8343
+ try {
8344
+ return await this.read(command, show) === void 0 ? void 0 : true;
8345
+ } catch (error) {
8346
+ if (error instanceof ExecError)
8347
+ return false;
8348
+ throw error;
8349
+ }
8350
+ }
8351
+ /** Close the shared connection; nothing to close when no call was made. */
8352
+ close() {
8353
+ return this.ctx.apply([run("ssh", ["-o", `ControlPath=${this.socket}`, "-O", "exit", this.host])]);
8354
+ }
8355
+ };
8356
+ async function deploy(ctx, options) {
8357
+ const { host, tenant, app } = options;
8358
+ const dir = options.dir ?? ".";
8359
+ const image = options.image || void 0;
8360
+ const appDir = options.appDir || `/opt/stacks/${tenant}/${app}`;
8361
+ const source = options.source || `apps/${app}`;
8362
+ const retries = options.retries ?? 30;
8363
+ let compose;
8364
+ let text = "";
8365
+ for (const candidate of [`${source}/docker/compose.yaml`, `${source}/compose.yaml`]) {
8366
+ try {
8367
+ text = await ctx.readFile(join5(dir, candidate));
8368
+ compose = candidate;
8369
+ break;
8370
+ } catch {
8371
+ }
8372
+ }
8373
+ if (compose === void 0)
8374
+ throw new UsageError(`no compose.yaml under ${source}; run from the app's source repo (\xA7 2 PLX-App)`);
8375
+ const rendered = renderCompose(text, image, compose);
8376
+ ctx.say(`\u2192 deploying ${image ?? "<image pinned in compose.yaml>"}`);
8377
+ ctx.say(` host: ${host}`);
8378
+ ctx.say(` dir: ${appDir}`);
8379
+ ctx.say(` compose: ${compose}`);
8380
+ ctx.say(` health: ${options.healthUrl || "<resolved on host>"}`);
8381
+ const remote = new Remote(ctx, host);
8382
+ const D = q(appDir);
8383
+ const dc = `cd ${D} && docker compose`;
8384
+ const dci = `${dc} --project-directory . -f ${INCOMING}/compose.yaml`;
8385
+ const liveImage = `${dc} ps -q web 2>/dev/null | head -1 | xargs -r docker inspect --format '{{.Config.Image}}' 2>/dev/null || true`;
8386
+ const result = { host, appDir, compose, image, healthy: false, rolledBack: false };
8387
+ let staged = false;
8388
+ try {
8389
+ await remote.exec(`rm -rf ${D}/${INCOMING} && mkdir -p ${D}/${INCOMING} && cat > ${D}/${INCOMING}/compose.yaml`, rendered);
8390
+ staged = true;
8391
+ result.previous = await remote.read(liveImage) || void 0;
8392
+ ctx.say(` previous: ${result.previous ?? (ctx.dryRun ? "<read on host>" : "<none>")}`);
8393
+ await remote.exec(`${dci} pull`);
8394
+ const migrate = await remote.test(`${dci} --profile migrate config --services 2>/dev/null | grep -qx migrate`);
8395
+ if (migrate === void 0)
8396
+ ctx.say(" (if compose.yaml declares a migrate service:)");
8397
+ if (migrate !== false)
8398
+ await remote.exec(`${dci} run --rm migrate </dev/null`);
8399
+ const hadPrev = await remote.test(`test -f ${D}/compose.yaml`);
8400
+ if (hadPrev !== false)
8401
+ await remote.exec(`cd ${D} && cp -p compose.yaml ${INCOMING}/compose.yaml.prev`);
8402
+ await remote.exec(`cd ${D} && mv ${INCOMING}/compose.yaml compose.yaml`);
8403
+ await remote.exec(`${dc} up -d`);
8404
+ const url = options.healthUrl || await resolveHealthUrl(remote, D, dc, ctx);
8405
+ const poll = `cd ${D} && for _ in $(seq 1 ${retries}); do curl -fsS --max-time 5 -o /dev/null ${q(url)} && exit 0; sleep 2; done; exit 1`;
8406
+ const started = Date.now();
8407
+ const healthy = await remote.test(poll, true);
8408
+ if (healthy !== false) {
8409
+ result.live = await remote.read(liveImage) || image;
8410
+ result.healthy = true;
8411
+ if (ctx.dryRun)
8412
+ ctx.say(" (on failure: restore the previous compose.yaml, up -d, poll again)");
8413
+ else
8414
+ ctx.done(`${result.live ?? "the app"} is live and healthy`);
8415
+ return result;
8416
+ }
8417
+ ctx.say(`\u2717 healthcheck failed after ${Math.round((Date.now() - started) / 1e3)}s; last lines from the app:`);
8418
+ const logs = await remote.read(`${dc} logs --no-color --tail 50 web 2>&1 || true`);
8419
+ if (logs)
8420
+ ctx.say(logs.replace(/^/gm, " "));
8421
+ const differs = hadPrev && await remote.test(`cd ${D} && ! cmp -s compose.yaml ${INCOMING}/compose.yaml.prev`);
8422
+ if (!differs) {
8423
+ ctx.done("nothing to roll back to (first deploy, or the same run shape re-deployed); manual intervention needed", false);
8424
+ return result;
8425
+ }
8426
+ await remote.exec(`cd ${D} && cp -p ${INCOMING}/compose.yaml.prev compose.yaml`);
8427
+ ctx.say(`\u21A9 rolling back to ${result.previous ?? "the previous compose.yaml"}`);
8428
+ await remote.exec(`${dc} up -d`);
8429
+ result.rolledBack = true;
8430
+ if (await remote.test(poll)) {
8431
+ result.live = result.previous;
8432
+ ctx.done(`rolled back to ${result.previous ?? "the previous compose.yaml"}`);
8433
+ } else {
8434
+ ctx.done("rollback also unhealthy; manual intervention needed", false);
8435
+ }
8436
+ return result;
8437
+ } finally {
8438
+ if (staged)
8439
+ await remote.exec(`rm -rf ${D}/${INCOMING}`).catch(() => void 0);
8440
+ await remote.close().catch(() => void 0);
8441
+ }
8442
+ }
8443
+ async function resolveHealthUrl(remote, D, dc, ctx) {
8444
+ const port = await remote.read(`sed -n 's/^PLEXUS_LOOPBACK_PORT=//p' ${D}/.env 2>/dev/null | tail -1`);
8445
+ const path3 = await remote.read(`${dc} ps -q web 2>/dev/null | head -1 | xargs -r docker inspect --format '{{ index .Config.Labels "plexus.healthz" }}' 2>/dev/null || true`);
8446
+ const url = ctx.dryRun ? "http://127.0.0.1:${PLEXUS_LOOPBACK_PORT:-3000}${plexus.healthz:-/healthz}" : `http://127.0.0.1:${port || "3000"}${path3 || "/healthz"}`;
8447
+ ctx.say(` health: ${url}`);
8448
+ return url;
8449
+ }
8450
+
8451
+ // ../lib/dist/deployTargets.js
8452
+ var import_yaml4 = __toESM(require_dist(), 1);
8453
+ import { join as join6 } from "node:path";
8454
+ var VARS_DIRS = ["group_vars", "host_vars"];
8455
+ function parseInventoryFile(path3, text) {
8456
+ const data = (0, import_yaml4.parse)(text);
8457
+ const vars = data !== null && typeof data === "object" ? data : {};
8458
+ const target = typeof vars.plexus_deploy_target === "string" ? vars.plexus_deploy_target : void 0;
8459
+ const records = Array.isArray(vars.plexus_apps) ? vars.plexus_apps : [];
8460
+ const apps = records.map((r) => r !== null && typeof r === "object" ? r.name : void 0).filter((n) => typeof n === "string");
8461
+ return { path: path3, target, apps };
8462
+ }
8463
+ function selectDeployTargets(files) {
8464
+ const targets = {};
8465
+ const declaredIn = {};
8466
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
8467
+ if (file.target === void 0)
8468
+ continue;
8469
+ for (const app of file.apps) {
8470
+ if (app in targets)
8471
+ throw new Error(`app "${app}" is declared with a deploy target in both ${declaredIn[app]} and ${file.path}`);
8472
+ targets[app] = file.target;
8473
+ declaredIn[app] = file.path;
8474
+ }
8475
+ }
8476
+ return Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b)));
8477
+ }
8478
+ async function readDeployTargets(ctx, inventory) {
8479
+ const files = [];
8480
+ let found = false;
8481
+ for (const sub of VARS_DIRS) {
8482
+ const dir = join6(inventory, sub);
8483
+ const entries = await ctx.readDir(dir).catch(() => void 0);
8484
+ if (entries === void 0)
8485
+ continue;
8486
+ found = true;
8487
+ for (const e of entries) {
8488
+ if (e.directory || !/\.ya?ml$/.test(e.name))
8489
+ continue;
8490
+ const path3 = join6(dir, e.name);
8491
+ files.push(parseInventoryFile(path3, await ctx.readFile(path3)));
8492
+ }
8493
+ }
8494
+ if (!found && await ctx.readDir(inventory).catch(() => void 0) === void 0)
8495
+ throw new UsageError(`no inventory directory at ${inventory}`);
8496
+ return selectDeployTargets(files);
8497
+ }
8498
+
8131
8499
  // ../lib/dist/model.js
8132
8500
  function kindOf(declaration) {
8133
8501
  const has = (code) => declaration.modules.includes(code);
@@ -8149,7 +8517,7 @@ function isNotable(dir) {
8149
8517
  }
8150
8518
 
8151
8519
  // ../lib/dist/overview.js
8152
- import { join as join3 } from "node:path";
8520
+ import { join as join7 } from "node:path";
8153
8521
  var PRUNED = /* @__PURE__ */ new Set([
8154
8522
  "node_modules",
8155
8523
  ".hg",
@@ -8197,7 +8565,7 @@ async function walk(ctx, path3, name, options) {
8197
8565
  }
8198
8566
  const subdirs = entries.filter((e) => e.directory && e.name !== ".git" && !PRUNED.has(e.name)).map((e) => e.name).sort();
8199
8567
  for (const sub of subdirs) {
8200
- const child = await walk(ctx, join3(path3, sub), sub, options);
8568
+ const child = await walk(ctx, join7(path3, sub), sub, options);
8201
8569
  if (isNotable(child) || child.children.length > 0)
8202
8570
  dir.children.push(child);
8203
8571
  }
@@ -8205,7 +8573,7 @@ async function walk(ctx, path3, name, options) {
8205
8573
  }
8206
8574
  async function facet(ctx, dir, file, read) {
8207
8575
  try {
8208
- read(await ctx.readFile(join3(dir.path, file)));
8576
+ read(await ctx.readFile(join7(dir.path, file)));
8209
8577
  } catch (error) {
8210
8578
  dir.problems ??= [];
8211
8579
  dir.problems.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
@@ -8244,93 +8612,12 @@ async function templateFacet(ctx, cwd) {
8244
8612
  }
8245
8613
 
8246
8614
  // ../lib/dist/propagate.js
8247
- import { join as join5, relative as relative3 } from "node:path";
8248
-
8249
- // ../lib/dist/train.js
8250
- import { join as join4 } from "node:path";
8251
- var TRAIN_REPOS = [
8252
- "platform",
8253
- "ci-cd",
8254
- "preset-repo-web",
8255
- "preset-app-nextjs",
8256
- "preset-repo-platform",
8257
- "compendium"
8258
- ];
8259
- var UPSTREAM = "plexus-ms";
8260
- var isTrainVersion = (v) => /^\d+\.\d+\.\d+$/.test(v);
8261
- function nextMinor(version2) {
8262
- const [major = "0", minor = "0"] = version2.split(".");
8263
- return `${major}.${Number(minor) + 1}.0`;
8264
- }
8265
- function latestTag(tags) {
8266
- return tags.filter((t) => /^v?\d/.test(t)).sort((a, b) => compareTrains(a.replace(/^v/, ""), b.replace(/^v/, ""))).at(-1);
8267
- }
8268
- async function readTrain(ctx, upstream) {
8269
- const repos = [];
8270
- const all = /* @__PURE__ */ new Set();
8271
- for (const name of TRAIN_REPOS) {
8272
- const path3 = join4(upstream, name);
8273
- try {
8274
- await ctx.read("git", ["rev-parse", "--git-dir"], { cwd: path3 });
8275
- } catch {
8276
- throw new Error(`${path3} is not a git repo`);
8277
- }
8278
- try {
8279
- await ctx.read("git", ["fetch", "origin", "--tags", "--quiet"], { cwd: path3 });
8280
- } catch {
8281
- throw new Error(`${name}: fetch from origin failed`);
8282
- }
8283
- const tags = (await ctx.read("git", ["tag", "--list", "v*", "[0-9]*"], { cwd: path3 })).split("\n").filter(Boolean);
8284
- for (const t of tags)
8285
- all.add(t.replace(/^v/, ""));
8286
- const tag = latestTag(tags);
8287
- const range = tag ? `${tag}..HEAD` : "HEAD";
8288
- const pending = (await ctx.read("git", ["log", "--oneline", range], { cwd: path3 })).split("\n").filter(Boolean);
8289
- repos.push({ name, path: path3, tag, latest: tag?.replace(/^v/, ""), pending });
8290
- }
8291
- const latest = repos.map((r) => r.latest);
8292
- return { repos, last: latestTag([...all]), skew: latest.some((l) => l !== latest[0]) };
8293
- }
8294
- async function preflightRelease(ctx, train) {
8295
- for (const { name, path: path3 } of train.repos) {
8296
- const branch = (await ctx.read("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path3 })).trim();
8297
- if (branch !== "main")
8298
- throw new Error(`${name} is on '${branch}', trains cut from main`);
8299
- if ((await ctx.read("git", ["status", "--porcelain"], { cwd: path3 })).trim() !== "")
8300
- throw new Error(`${name} has uncommitted changes \u2014 commit first`);
8301
- const behind = (await ctx.read("git", ["rev-list", "--count", "main..origin/main"], { cwd: path3 })).trim();
8302
- if (behind !== "0")
8303
- throw new Error(`${name} is ${behind} commit(s) behind origin/main \u2014 pull first, or the tag lands on a stale commit`);
8304
- }
8305
- }
8306
- function resolveVersion(train, explicit, propagateOnly) {
8307
- if (explicit !== void 0 && !isTrainVersion(explicit))
8308
- throw new UsageError(`'${explicit}' is not plain semver (X.Y.Z, no v)`);
8309
- if (propagateOnly) {
8310
- const v = explicit ?? train.last;
8311
- if (!v)
8312
- throw new Error("no train version found to propagate");
8313
- return v;
8314
- }
8315
- if (explicit)
8316
- return explicit;
8317
- if (train.skew) {
8318
- const report = train.repos.map((r) => ` ${r.name}: ${r.latest ?? "none"}`).join("\n");
8319
- throw new Error(`the train repos' latest tags disagree:
8320
- ${report}
8321
- pass the version explicitly (--train ${train.last}) to finish the half-tagged train, or a higher one to start a new one`);
8322
- }
8323
- return train.last ? nextMinor(train.last) : "0.1.0";
8324
- }
8325
- var UsageError = class extends Error {
8326
- };
8327
-
8328
- // ../lib/dist/propagate.js
8615
+ import { join as join8, relative as relative3 } from "node:path";
8329
8616
  var isClean = (s) => s.conflicted.length === 0 && s.failed.length === 0 && s.skipped.length === 0;
8330
8617
  var PREVIEW_BASE = "ship-base";
8331
8618
  async function propagate(ctx, workspace, train) {
8332
8619
  const summary = { train, preview: ctx.dryRun, clean: [], conflicted: [], failed: [], skipped: [] };
8333
- const upstream = join5(workspace, UPSTREAM);
8620
+ const upstream = join8(workspace, UPSTREAM);
8334
8621
  const tree = await overview(ctx, workspace);
8335
8622
  const upstreamDir = tree.children.find((c) => c.path === upstream);
8336
8623
  const presets = TRAIN_REPOS.filter((r) => upstreamDir?.children.some((c) => c.name === r && c.template));
@@ -8347,7 +8634,7 @@ async function propagate(ctx, workspace, train) {
8347
8634
  const cloneOf = async (consumer) => {
8348
8635
  let clone = clones.get(consumer.path);
8349
8636
  if (!clone) {
8350
- clone = join5(scratch ?? "", consumer.rel);
8637
+ clone = join8(scratch ?? "", consumer.rel);
8351
8638
  await ctx.stage(run("git", ["clone", "--quiet", consumer.path, clone]));
8352
8639
  await ctx.stage(run("git", ["tag", PREVIEW_BASE], { cwd: clone }));
8353
8640
  clones.set(consumer.path, clone);
@@ -8374,7 +8661,7 @@ async function propagate(ctx, workspace, train) {
8374
8661
  if (sites.length === 0)
8375
8662
  ctx.say(`\u2022 no consumers of ${presetSource(preset)} found under ${workspace}`);
8376
8663
  for (const [consumer, site] of sites)
8377
- await updateSite(ctx, consumer, site, join5(upstream, preset), summary, cloneOf, previewCommit);
8664
+ await updateSite(ctx, consumer, site, join8(upstream, preset), summary, cloneOf, previewCommit);
8378
8665
  }
8379
8666
  await refreshLockfiles(ctx, active, summary, clones);
8380
8667
  await bumpConsumerRefs(ctx, consumers, train);
@@ -8391,9 +8678,9 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8391
8678
  if (ctx.dryRun) {
8392
8679
  ctx.say(`\u2192 ${site.rel}: copier update (preview from ${localPreset}@main)`);
8393
8680
  repo = await cloneOf(consumer);
8394
- target = join5(repo, relative3(consumer.path, site.path));
8395
- const answers = await ctx.readFile(join5(site.path, ANSWERS_FILE));
8396
- await ctx.stage(write(join5(target, ANSWERS_FILE), answers.replace(/^_src_path: .*$/m, `_src_path: ${localPreset}`)));
8681
+ target = join8(repo, relative3(consumer.path, site.path));
8682
+ const answers = await ctx.readFile(join8(site.path, ANSWERS_FILE));
8683
+ await ctx.stage(write(join8(target, ANSWERS_FILE), answers.replace(/^_src_path: .*$/m, `_src_path: ${localPreset}`)));
8397
8684
  await previewCommit(repo, `plx preview: ${site.rel} reads ${localPreset} from the local checkout`);
8398
8685
  await ctx.stage(run("copier", ["update", "--skip-answered", "--trust", "--defaults", "--skip-tasks", "--vcs-ref", "main"], {
8399
8686
  cwd: target
@@ -8412,7 +8699,7 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8412
8699
  let conflict = hasUnmergedEntries(status);
8413
8700
  const changed = (await ctx.read("git", ["diff", "--name-only"], { cwd: repo })).split("\n").filter(Boolean);
8414
8701
  for (const f of changed) {
8415
- const text = await ctx.readFile(join5(repo, f)).catch(() => "");
8702
+ const text = await ctx.readFile(join8(repo, f)).catch(() => "");
8416
8703
  if (hasConflictMarkers(text))
8417
8704
  conflict = true;
8418
8705
  }
@@ -8430,17 +8717,17 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8430
8717
  await previewCommit(repo, `plx preview: ${site.rel} updated`);
8431
8718
  }
8432
8719
  async function syncSiteMarker(ctx, site, target) {
8433
- const marker = await ctx.readFile(join5(target, MARKER_FILE)).catch(() => void 0);
8720
+ const marker = await ctx.readFile(join8(target, MARKER_FILE)).catch(() => void 0);
8434
8721
  if (marker === void 0)
8435
8722
  return;
8436
- const { commit } = parseAnswers(await ctx.readFile(join5(target, ANSWERS_FILE)));
8723
+ const { commit } = parseAnswers(await ctx.readFile(join8(target, ANSWERS_FILE)));
8437
8724
  if (!commit)
8438
8725
  return;
8439
8726
  const synced = syncMarker(marker, commit);
8440
8727
  if (synced === marker)
8441
8728
  return;
8442
8729
  ctx.say(` marker: plexus \u2192 ${commit} in ${site.rel}/${MARKER_FILE}`);
8443
- const step = write(join5(target, MARKER_FILE), synced);
8730
+ const step = write(join8(target, MARKER_FILE), synced);
8444
8731
  if (ctx.dryRun)
8445
8732
  await ctx.stage(step);
8446
8733
  else
@@ -8449,7 +8736,7 @@ async function syncSiteMarker(ctx, site, target) {
8449
8736
  async function refreshLockfiles(ctx, consumers, summary, clones) {
8450
8737
  const manifests = ["*package.json", "pnpm-workspace.yaml"];
8451
8738
  for (const c of consumers) {
8452
- if (await ctx.readFile(join5(c.path, "pnpm-workspace.yaml")).catch(() => void 0) === void 0)
8739
+ if (await ctx.readFile(join8(c.path, "pnpm-workspace.yaml")).catch(() => void 0) === void 0)
8453
8740
  continue;
8454
8741
  const repo = ctx.dryRun ? clones.get(c.path) : c.path;
8455
8742
  if (!repo)
@@ -8469,7 +8756,7 @@ async function refreshLockfiles(ctx, consumers, summary, clones) {
8469
8756
  }
8470
8757
  async function bumpConsumerRefs(ctx, consumers, train) {
8471
8758
  for (const c of consumers) {
8472
- const dir = join5(c.path, ".github", "workflows");
8759
+ const dir = join8(c.path, ".github", "workflows");
8473
8760
  const entries = await ctx.readDir(dir).catch(() => []);
8474
8761
  const files = entries.filter((e) => !e.directory && /\.ya?ml$/.test(e.name)).map((e) => e.name).sort();
8475
8762
  if (files.length === 0)
@@ -8479,14 +8766,14 @@ async function bumpConsumerRefs(ctx, consumers, train) {
8479
8766
  continue;
8480
8767
  }
8481
8768
  for (const f of files) {
8482
- const text = await ctx.readFile(join5(dir, f));
8769
+ const text = await ctx.readFile(join8(dir, f));
8483
8770
  if (!text.includes(WORKFLOW_REF))
8484
8771
  continue;
8485
8772
  const bumped = bumpWorkflowRefs(text, train);
8486
8773
  if (bumped === text)
8487
8774
  continue;
8488
8775
  ctx.say(`\u2192 ${c.rel}: ${f} ci-cd refs \u2192 @${train}`);
8489
- await ctx.apply([write(join5(dir, f), bumped)]);
8776
+ await ctx.apply([write(join8(dir, f), bumped)]);
8490
8777
  }
8491
8778
  }
8492
8779
  }
@@ -8497,7 +8784,7 @@ var detail = (error) => {
8497
8784
  };
8498
8785
 
8499
8786
  // ../lib/dist/requirements.js
8500
- import { join as join6 } from "node:path";
8787
+ import { join as join9 } from "node:path";
8501
8788
  var KEYWORD = /MUST|SHOULD|MAY/;
8502
8789
  var hasKeyword = (line) => KEYWORD.test(line.replace(/MUSTs|SHOULDs|MAYs/g, ""));
8503
8790
  var list = (value) => value.replace(/^\s*\[/, "").replace(/\].*$/, "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -8590,7 +8877,7 @@ var MAP_PAGE = "essentials/standard.md";
8590
8877
  async function modulePages(ctx, compendium) {
8591
8878
  const pages = [];
8592
8879
  for (const dir of Object.keys(CATALOG_DIRS)) {
8593
- const entries = await ctx.readDir(join6(compendium, dir));
8880
+ const entries = await ctx.readDir(join9(compendium, dir));
8594
8881
  for (const e of entries.filter((e2) => !e2.directory && e2.name.endsWith(".md") && e2.name !== "index.md").sort())
8595
8882
  pages.push(`${dir}/${e.name}`);
8596
8883
  }
@@ -8598,11 +8885,11 @@ async function modulePages(ctx, compendium) {
8598
8885
  }
8599
8886
  async function checkCompendium(ctx, compendium) {
8600
8887
  const defects = [];
8601
- defects.push(...scanPage(await ctx.readFile(join6(compendium, MAP_PAGE)), MAP_PAGE, "map").defects);
8888
+ defects.push(...scanPage(await ctx.readFile(join9(compendium, MAP_PAGE)), MAP_PAGE, "map").defects);
8602
8889
  const pages = await modulePages(ctx, compendium);
8603
8890
  const scans = /* @__PURE__ */ new Map();
8604
8891
  for (const page of pages) {
8605
- const scan = scanPage(await ctx.readFile(join6(compendium, page)), page, "module");
8892
+ const scan = scanPage(await ctx.readFile(join9(compendium, page)), page, "module");
8606
8893
  defects.push(...scan.defects);
8607
8894
  scans.set(page, scan);
8608
8895
  }
@@ -8649,9 +8936,9 @@ async function checkCompendium(ctx, compendium) {
8649
8936
  }
8650
8937
 
8651
8938
  // ../lib/dist/ship.js
8652
- import { join as join7 } from "node:path";
8939
+ import { join as join10 } from "node:path";
8653
8940
  async function ship(ctx, options) {
8654
- const upstream = join7(options.workspace, UPSTREAM);
8941
+ const upstream = join10(options.workspace, UPSTREAM);
8655
8942
  const propagateOnly = options.propagateOnly ?? false;
8656
8943
  const train = await readTrain(ctx, upstream);
8657
8944
  if (!propagateOnly)
@@ -8702,7 +8989,7 @@ async function planBookkeeping(ctx, train, next, today2) {
8702
8989
  return r;
8703
8990
  };
8704
8991
  const edit = async (r, rel, what, transform) => {
8705
- const path3 = join7(r.path, rel);
8992
+ const path3 = join10(r.path, rel);
8706
8993
  const text = await ctx.readFile(path3).catch(() => void 0);
8707
8994
  if (text === void 0)
8708
8995
  return;
@@ -8723,7 +9010,7 @@ async function planBookkeeping(ctx, train, next, today2) {
8723
9010
  const comp = repo("compendium");
8724
9011
  await edit(comp, MAP_PAGE, `version \u2192 ${next}, timestamp \u2192 ${today2}`, (t) => stampStandard(t, next, today2));
8725
9012
  for (const page of await modulePages(ctx, comp.path)) {
8726
- const text = await ctx.readFile(join7(comp.path, page));
9013
+ const text = await ctx.readFile(join10(comp.path, page));
8727
9014
  if (frontmatterField(text, "maturity") !== "standard")
8728
9015
  continue;
8729
9016
  const previous = comp.tag ? await ctx.read("git", ["show", `${comp.tag}:${page}`], { cwd: comp.path }).catch(() => "") : "";
@@ -9686,13 +9973,13 @@ import { AsyncResource as AsyncResource3 } from "node:async_hooks";
9686
9973
 
9687
9974
  // ../../node_modules/.pnpm/@inquirer+type@4.2.0_@types+node@26.6.5/node_modules/@inquirer/type/dist/utils.js
9688
9975
  function withResolver() {
9689
- let resolve7;
9976
+ let resolve10;
9690
9977
  let reject;
9691
9978
  const promise = new Promise((res, rej) => {
9692
- resolve7 = res;
9979
+ resolve10 = res;
9693
9980
  reject = rej;
9694
9981
  });
9695
- return { promise, resolve: resolve7, reject };
9982
+ return { promise, resolve: resolve10, reject };
9696
9983
  }
9697
9984
 
9698
9985
  // ../../node_modules/.pnpm/@inquirer+core@12.0.5_@types+node@26.6.5/node_modules/@inquirer/core/dist/lib/create-prompt.js
@@ -10071,7 +10358,7 @@ function createPrompt(view) {
10071
10358
  });
10072
10359
  output.mute();
10073
10360
  const screen = new ScreenManager(rl);
10074
- const { promise, resolve: resolve7, reject } = withResolver();
10361
+ const { promise, resolve: resolve10, reject } = withResolver();
10075
10362
  return withHooks(rl, (cycle) => {
10076
10363
  const clearEffects = AsyncResource3.bind(() => effectScheduler.clearAll());
10077
10364
  const settlePrompt = (settle) => {
@@ -10082,7 +10369,7 @@ function createPrompt(view) {
10082
10369
  reject(error);
10083
10370
  }
10084
10371
  };
10085
- const resolvePrompt = (value) => settlePrompt(() => resolve7(value));
10372
+ const resolvePrompt = (value) => settlePrompt(() => resolve10(value));
10086
10373
  const rejectPrompt = (error) => settlePrompt(() => reject(error));
10087
10374
  const promptPromise = Object.assign(promise.finally(() => {
10088
10375
  cleanups.forEach((cleanup) => cleanup());
@@ -13601,12 +13888,22 @@ function useColor() {
13601
13888
  // ../../node_modules/.pnpm/commander@15.0.0/node_modules/commander/index.js
13602
13889
  var program = new Command();
13603
13890
 
13604
- // src/commands/changeset.ts
13891
+ // src/commands/changed-apps.ts
13605
13892
  import { resolve } from "node:path";
13606
13893
  function register(program2) {
13894
+ program2.command("changed-apps").description("list the apps whose sources changed between two refs, dependents of a changed package included").argument("<from>", "the base ref; empty, all zeros or unknown lists every app").argument("[to]", "the ref compared against", "HEAD").option("-C, --dir <dir>", "the checkout to read", ".").action(async (from, to, opts, command) => {
13895
+ const ctx = contextOf2(command);
13896
+ const result = await readChangedApps(ctx, resolve(opts.dir), from, to);
13897
+ ctx.result(result.apps);
13898
+ });
13899
+ }
13900
+
13901
+ // src/commands/changeset.ts
13902
+ import { resolve as resolve2 } from "node:path";
13903
+ function register2(program2) {
13607
13904
  program2.command("changeset").description("write the changeset a dependency bump owes: a patch per package whose runtime dependencies moved").argument("[dir]", "a checkout of the package repo, on the bump branch", ".").option("--base <ref>", "the trunk the branch is diffed against, at the merge base", "origin/main").option("--branch <name>", "the branch name that names the file (default: the checked-out branch)").action(async (dir, opts, command) => {
13608
13905
  const ctx = contextOf2(command);
13609
- const changeset = await readChangeset(ctx, resolve(dir), opts.base, opts.branch);
13906
+ const changeset = await readChangeset(ctx, resolve2(dir), opts.base, opts.branch);
13610
13907
  await ctx.apply(changeset.plan);
13611
13908
  ctx.result(changeset, renderChangeset);
13612
13909
  });
@@ -13617,9 +13914,49 @@ function renderChangeset(c) {
13617
13914
  return [`\u2713 ${c.path}`, ...releases.map(([name, bump]) => ` ${name}: ${bump}`)].join("\n");
13618
13915
  }
13619
13916
 
13917
+ // src/commands/deploy.ts
13918
+ import { resolve as resolve3 } from "node:path";
13919
+ function register3(program2) {
13920
+ program2.command("deploy").description(
13921
+ "render the app\u2019s compose file with the image pinned, stage it on the host, pull, migrate, swap, up, poll readiness, roll back on failure"
13922
+ ).argument("<host>", "ssh target, deploy@host").argument("<tenant>", "tenant slug, the first segment of the app dir on the host").argument("<app>", "the app, apps/<app> in the checkout").option("--image <ref>", "the image ref to pin; absent for a third-party app whose compose.yaml pins its own").option(
13923
+ "--health-url <url>",
13924
+ "readiness URL polled on the host (default: /healthz, or the plexus.healthz label, on the loopback port)"
13925
+ ).option("--retries <n>", "poll attempts, two seconds apart", "30").option("--app-dir <path>", "the app dir on the host (default: /opt/stacks/<tenant>/<app>)").option("--source <path>", "the app\u2019s dir in the checkout (default: apps/<app>)").option("-C, --dir <dir>", "the checkout to read", ".").action(async (host, tenant, app, opts, command) => {
13926
+ const ctx = contextOf2(command);
13927
+ const result = await deploy(ctx, {
13928
+ host,
13929
+ tenant,
13930
+ app,
13931
+ image: opts.image,
13932
+ healthUrl: opts.healthUrl,
13933
+ retries: Number(opts.retries),
13934
+ appDir: opts.appDir,
13935
+ source: opts.source,
13936
+ dir: resolve3(opts.dir)
13937
+ });
13938
+ ctx.result(result, renderDeploy);
13939
+ if (!result.healthy) process.exitCode = 1;
13940
+ });
13941
+ }
13942
+ function renderDeploy(d) {
13943
+ if (d.healthy) return d.live ?? "";
13944
+ return d.rolledBack ? `rolled back to ${d.live ?? "the previous compose.yaml"}` : "not healthy";
13945
+ }
13946
+
13947
+ // src/commands/deploy-targets.ts
13948
+ import { resolve as resolve4 } from "node:path";
13949
+ function register4(program2) {
13950
+ program2.command("deploy-targets").description("map each app of a platform repo to the deploy target of the inventory file that declares it").argument("[inventory]", "the inventory directory", "inventory").action(async (inventory, _opts, command) => {
13951
+ const ctx = contextOf2(command);
13952
+ const targets = await readDeployTargets(ctx, resolve4(inventory));
13953
+ ctx.result(targets, (t) => JSON.stringify(t));
13954
+ });
13955
+ }
13956
+
13620
13957
  // src/commands/doctor.ts
13621
13958
  import { existsSync } from "node:fs";
13622
- import { join as join8, resolve as resolve2 } from "node:path";
13959
+ import { join as join11, resolve as resolve5 } from "node:path";
13623
13960
 
13624
13961
  // src/style.ts
13625
13962
  function style(enabled) {
@@ -13630,15 +13967,15 @@ var colorByDefault = () => process.stdout.isTTY === true && !process.env.NO_COLO
13630
13967
 
13631
13968
  // src/commands/doctor.ts
13632
13969
  var WORKSPACE_COMPENDIUM = "plexus-ms/compendium";
13633
- function register2(program2) {
13970
+ function register5(program2) {
13634
13971
  program2.command("doctor").description("check every PLEXUS.md under a directory for modules that changed since and dependencies not declared").argument("[dir]", "directory to check", ".").option(
13635
13972
  "--compendium <path>",
13636
13973
  `compendium checkout to read the modules from (default: <dir>/${WORKSPACE_COMPENDIUM})`
13637
13974
  ).option("--color", "colour the report even when piped").option("--no-color", "never colour the report").action(async (dir, opts, command) => {
13638
13975
  const ctx = contextOf2(command);
13639
- const root = resolve2(dir);
13640
- const compendium = resolve2(opts.compendium ?? join8(root, WORKSPACE_COMPENDIUM));
13641
- if (!existsSync(join8(compendium, "contracts")))
13976
+ const root = resolve5(dir);
13977
+ const compendium = resolve5(opts.compendium ?? join11(root, WORKSPACE_COMPENDIUM));
13978
+ if (!existsSync(join11(compendium, "contracts")))
13642
13979
  throw new Error(
13643
13980
  `no compendium at ${compendium}; pass --compendium <path to a checkout of plexus-ms/compendium>`
13644
13981
  );
@@ -13666,11 +14003,11 @@ function renderReport(report, s, showCurrent) {
13666
14003
  }
13667
14004
 
13668
14005
  // src/commands/overview.ts
13669
- import { resolve as resolve3 } from "node:path";
13670
- function register3(program2) {
14006
+ import { resolve as resolve6 } from "node:path";
14007
+ function register6(program2) {
13671
14008
  program2.command("overview").description("map the git repos, copier templates and projects, and Plexus units under a directory").argument("[dir]", "directory to map", ".").option("--color", "colour the tree even when piped").option("--no-color", "never colour the tree").action(async (dir, opts, command) => {
13672
14009
  const ctx = contextOf2(command);
13673
- const tree = await overview(ctx, resolve3(dir));
14010
+ const tree = await overview(ctx, resolve6(dir));
13674
14011
  const s = style(opts.color ?? colorByDefault());
13675
14012
  ctx.result(tree, (t) => renderTree(t, s, ctx.verbose >= 1));
13676
14013
  });
@@ -13713,14 +14050,14 @@ function annotate(dir, s, modules) {
13713
14050
  }
13714
14051
 
13715
14052
  // src/commands/propagate.ts
13716
- import { resolve as resolve5 } from "node:path";
14053
+ import { resolve as resolve8 } from "node:path";
13717
14054
 
13718
14055
  // src/commands/ship.ts
13719
- import { resolve as resolve4 } from "node:path";
13720
- function register4(program2) {
14056
+ import { resolve as resolve7 } from "node:path";
14057
+ function register7(program2) {
13721
14058
  program2.command("ship").description("release the train: tag every train repo identically, then copier-update every consumer").argument("[dir]", "the workspace: plexus-ms/ and the consumers sit under it", ".").option("--train <version>", "the train version, plain semver (default: minor+1 over the latest train)").action(async (dir, opts, command) => {
13722
14059
  const ctx = contextOf2(command);
13723
- const result = await ship(ctx, { workspace: resolve4(dir), train: opts.train });
14060
+ const result = await ship(ctx, { workspace: resolve7(dir), train: opts.train });
13724
14061
  ctx.result(result, (r) => renderSummary(r, `plx -y ship --train ${r.train} ${dir}`));
13725
14062
  if (!isClean(result)) process.exitCode = 1;
13726
14063
  });
@@ -13743,23 +14080,23 @@ function renderSummary(s, realCommand) {
13743
14080
  }
13744
14081
 
13745
14082
  // src/commands/propagate.ts
13746
- function register5(program2) {
14083
+ function register8(program2) {
13747
14084
  program2.command("propagate").description("copier-update every consumer of the presets to the current train, and pin their ci-cd workflow refs").argument("[dir]", "the workspace: plexus-ms/ and the consumers sit under it", ".").option("--train <version>", "the train to propagate (default: the current one)").action(async (dir, opts, command) => {
13748
14085
  const ctx = contextOf2(command);
13749
- const result = await ship(ctx, { workspace: resolve5(dir), train: opts.train, propagateOnly: true });
14086
+ const result = await ship(ctx, { workspace: resolve8(dir), train: opts.train, propagateOnly: true });
13750
14087
  ctx.result(result, (r) => renderSummary(r, `plx -y propagate --train ${r.train} ${dir}`));
13751
14088
  if (!isClean(result)) process.exitCode = 1;
13752
14089
  });
13753
14090
  }
13754
14091
 
13755
14092
  // src/commands/requirements.ts
13756
- import { join as join9, resolve as resolve6 } from "node:path";
13757
- function register6(program2) {
14093
+ import { join as join12, resolve as resolve9 } from "node:path";
14094
+ function register9(program2) {
13758
14095
  program2.command("requirements").description("lint the standard's map and module pages, or extract one module's requirement lines").argument("[dir]", "a checkout of the compendium", ".").option("--extract <page>", "print a module page's requirement lines instead of linting (path relative to dir)").action(async (dir, opts, command) => {
13759
14096
  const ctx = contextOf2(command);
13760
- const compendium = resolve6(dir);
14097
+ const compendium = resolve9(dir);
13761
14098
  if (opts.extract) {
13762
- ctx.result(extractRequirements(await ctx.readFile(join9(compendium, opts.extract))));
14099
+ ctx.result(extractRequirements(await ctx.readFile(join12(compendium, opts.extract))));
13763
14100
  return;
13764
14101
  }
13765
14102
  const check = await checkCompendium(ctx, compendium);
@@ -13773,7 +14110,17 @@ function renderCheck(c) {
13773
14110
  }
13774
14111
 
13775
14112
  // src/commands/index.ts
13776
- var commands = [register3, register2, register4, register5, register6, register];
14113
+ var commands = [
14114
+ register6,
14115
+ register5,
14116
+ register7,
14117
+ register8,
14118
+ register9,
14119
+ register2,
14120
+ register,
14121
+ register4,
14122
+ register3
14123
+ ];
13777
14124
 
13778
14125
  // src/program.ts
13779
14126
  var { version, description } = createRequire(import.meta.url)("../package.json");
@@ -13782,7 +14129,7 @@ function buildProgram() {
13782
14129
  program2.addOption(new Option("-n, --dry-run", "print every effect this run would have, perform none").env("PLX_DRY_RUN")).addOption(
13783
14130
  new Option("-v, --verbose", "say more: once for reads and inputs, twice for command output").argParser((_value, previous) => previous + 1).default(0)
13784
14131
  ).addOption(new Option("-q, --quiet", "say nothing but results and errors").env("PLX_QUIET")).addOption(new Option("-y, --yes", "answer every confirmation with yes (needed off a terminal)").env("PLX_YES")).addOption(new Option("--json", "print results as JSON").env("PLX_JSON"));
13785
- for (const register7 of commands) register7(program2);
14132
+ for (const register10 of commands) register10(program2);
13786
14133
  return program2;
13787
14134
  }
13788
14135
  function contextOf2(command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexus-ms/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "plx, the Plexus command-line tool: a single-script adapter over @plexus-ms/lib",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -20,11 +20,11 @@
20
20
  "access": "public"
21
21
  },
22
22
  "engines": {
23
- "node": ">=24"
23
+ "node": ">=24.22.1"
24
24
  },
25
25
  "dependencies": {
26
26
  "@inquirer/prompts": "^8.7.4",
27
- "@plexus-ms/lib": "0.4.0",
27
+ "@plexus-ms/lib": "0.6.0",
28
28
  "commander": "^15.0.0"
29
29
  },
30
30
  "devDependencies": {