@plexus-ms/cli 0.5.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 +259 -67
  2. package/package.json +2 -2
package/dist/plx.js CHANGED
@@ -7754,7 +7754,7 @@ function realIo(prompt) {
7754
7754
  stdout: process.stdout,
7755
7755
  stderr: process.stderr,
7756
7756
  prompt: prompt && isInteractive() ? prompt : void 0,
7757
- exec: (command, args, options = {}) => new Promise((resolve9, reject) => {
7757
+ exec: (command, args, options = {}) => new Promise((resolve10, reject) => {
7758
7758
  const child = spawn(command, args, {
7759
7759
  cwd: options.cwd,
7760
7760
  env: options.env,
@@ -7777,7 +7777,7 @@ function realIo(prompt) {
7777
7777
  child.on("error", reject);
7778
7778
  child.on("close", (code) => {
7779
7779
  if (code === 0)
7780
- resolve9(stdout);
7780
+ resolve10(stdout);
7781
7781
  else
7782
7782
  reject(new ExecError(render(command, args), code, stderr));
7783
7783
  });
@@ -7808,8 +7808,6 @@ function describe(step) {
7808
7808
  switch (step.kind) {
7809
7809
  case "run":
7810
7810
  return `$ ${step.cwd ? `(cd ${step.cwd} && ` : ""}${render(step.command, step.args)}${step.cwd ? ")" : ""}`;
7811
- case "ssh":
7812
- return `$ ${render("ssh", [step.host, "bash", "-s"])} <<'EOF'`;
7813
7811
  case "write":
7814
7812
  return `> ${step.path} (${Buffer.byteLength(step.content)} bytes)`;
7815
7813
  }
@@ -7818,9 +7816,6 @@ function body(step) {
7818
7816
  switch (step.kind) {
7819
7817
  case "run":
7820
7818
  return step.input;
7821
- case "ssh":
7822
- return `${step.script.trimEnd()}
7823
- EOF`;
7824
7819
  case "write":
7825
7820
  return step.content;
7826
7821
  }
@@ -8110,8 +8105,6 @@ var Context = class {
8110
8105
  switch (step.kind) {
8111
8106
  case "run":
8112
8107
  return this.io.exec(step.command, step.args, { cwd: step.cwd, input: step.input, inherit });
8113
- case "ssh":
8114
- return this.io.exec("ssh", [step.host, "bash", "-s"], { input: step.script, inherit });
8115
8108
  case "write":
8116
8109
  await this.io.writeFile(step.path, step.content);
8117
8110
  return void 0;
@@ -8145,8 +8138,9 @@ function parseAnswers(text) {
8145
8138
  };
8146
8139
  }
8147
8140
 
8148
- // ../lib/dist/deployTargets.js
8149
- var import_yaml4 = __toESM(require_dist(), 1);
8141
+ // ../lib/dist/deploy.js
8142
+ import { randomBytes } from "node:crypto";
8143
+ import { tmpdir } from "node:os";
8150
8144
  import { join as join5 } from "node:path";
8151
8145
 
8152
8146
  // ../lib/dist/train.js
@@ -8289,7 +8283,174 @@ pass the version explicitly (--train ${train.last}) to finish the half-tagged tr
8289
8283
  var UsageError = class extends Error {
8290
8284
  };
8291
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
+
8292
8451
  // ../lib/dist/deployTargets.js
8452
+ var import_yaml4 = __toESM(require_dist(), 1);
8453
+ import { join as join6 } from "node:path";
8293
8454
  var VARS_DIRS = ["group_vars", "host_vars"];
8294
8455
  function parseInventoryFile(path3, text) {
8295
8456
  const data = (0, import_yaml4.parse)(text);
@@ -8318,7 +8479,7 @@ async function readDeployTargets(ctx, inventory) {
8318
8479
  const files = [];
8319
8480
  let found = false;
8320
8481
  for (const sub of VARS_DIRS) {
8321
- const dir = join5(inventory, sub);
8482
+ const dir = join6(inventory, sub);
8322
8483
  const entries = await ctx.readDir(dir).catch(() => void 0);
8323
8484
  if (entries === void 0)
8324
8485
  continue;
@@ -8326,7 +8487,7 @@ async function readDeployTargets(ctx, inventory) {
8326
8487
  for (const e of entries) {
8327
8488
  if (e.directory || !/\.ya?ml$/.test(e.name))
8328
8489
  continue;
8329
- const path3 = join5(dir, e.name);
8490
+ const path3 = join6(dir, e.name);
8330
8491
  files.push(parseInventoryFile(path3, await ctx.readFile(path3)));
8331
8492
  }
8332
8493
  }
@@ -8356,7 +8517,7 @@ function isNotable(dir) {
8356
8517
  }
8357
8518
 
8358
8519
  // ../lib/dist/overview.js
8359
- import { join as join6 } from "node:path";
8520
+ import { join as join7 } from "node:path";
8360
8521
  var PRUNED = /* @__PURE__ */ new Set([
8361
8522
  "node_modules",
8362
8523
  ".hg",
@@ -8404,7 +8565,7 @@ async function walk(ctx, path3, name, options) {
8404
8565
  }
8405
8566
  const subdirs = entries.filter((e) => e.directory && e.name !== ".git" && !PRUNED.has(e.name)).map((e) => e.name).sort();
8406
8567
  for (const sub of subdirs) {
8407
- const child = await walk(ctx, join6(path3, sub), sub, options);
8568
+ const child = await walk(ctx, join7(path3, sub), sub, options);
8408
8569
  if (isNotable(child) || child.children.length > 0)
8409
8570
  dir.children.push(child);
8410
8571
  }
@@ -8412,7 +8573,7 @@ async function walk(ctx, path3, name, options) {
8412
8573
  }
8413
8574
  async function facet(ctx, dir, file, read) {
8414
8575
  try {
8415
- read(await ctx.readFile(join6(dir.path, file)));
8576
+ read(await ctx.readFile(join7(dir.path, file)));
8416
8577
  } catch (error) {
8417
8578
  dir.problems ??= [];
8418
8579
  dir.problems.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
@@ -8451,12 +8612,12 @@ async function templateFacet(ctx, cwd) {
8451
8612
  }
8452
8613
 
8453
8614
  // ../lib/dist/propagate.js
8454
- import { join as join7, relative as relative3 } from "node:path";
8615
+ import { join as join8, relative as relative3 } from "node:path";
8455
8616
  var isClean = (s) => s.conflicted.length === 0 && s.failed.length === 0 && s.skipped.length === 0;
8456
8617
  var PREVIEW_BASE = "ship-base";
8457
8618
  async function propagate(ctx, workspace, train) {
8458
8619
  const summary = { train, preview: ctx.dryRun, clean: [], conflicted: [], failed: [], skipped: [] };
8459
- const upstream = join7(workspace, UPSTREAM);
8620
+ const upstream = join8(workspace, UPSTREAM);
8460
8621
  const tree = await overview(ctx, workspace);
8461
8622
  const upstreamDir = tree.children.find((c) => c.path === upstream);
8462
8623
  const presets = TRAIN_REPOS.filter((r) => upstreamDir?.children.some((c) => c.name === r && c.template));
@@ -8473,7 +8634,7 @@ async function propagate(ctx, workspace, train) {
8473
8634
  const cloneOf = async (consumer) => {
8474
8635
  let clone = clones.get(consumer.path);
8475
8636
  if (!clone) {
8476
- clone = join7(scratch ?? "", consumer.rel);
8637
+ clone = join8(scratch ?? "", consumer.rel);
8477
8638
  await ctx.stage(run("git", ["clone", "--quiet", consumer.path, clone]));
8478
8639
  await ctx.stage(run("git", ["tag", PREVIEW_BASE], { cwd: clone }));
8479
8640
  clones.set(consumer.path, clone);
@@ -8500,7 +8661,7 @@ async function propagate(ctx, workspace, train) {
8500
8661
  if (sites.length === 0)
8501
8662
  ctx.say(`\u2022 no consumers of ${presetSource(preset)} found under ${workspace}`);
8502
8663
  for (const [consumer, site] of sites)
8503
- await updateSite(ctx, consumer, site, join7(upstream, preset), summary, cloneOf, previewCommit);
8664
+ await updateSite(ctx, consumer, site, join8(upstream, preset), summary, cloneOf, previewCommit);
8504
8665
  }
8505
8666
  await refreshLockfiles(ctx, active, summary, clones);
8506
8667
  await bumpConsumerRefs(ctx, consumers, train);
@@ -8517,9 +8678,9 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8517
8678
  if (ctx.dryRun) {
8518
8679
  ctx.say(`\u2192 ${site.rel}: copier update (preview from ${localPreset}@main)`);
8519
8680
  repo = await cloneOf(consumer);
8520
- target = join7(repo, relative3(consumer.path, site.path));
8521
- const answers = await ctx.readFile(join7(site.path, ANSWERS_FILE));
8522
- await ctx.stage(write(join7(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}`)));
8523
8684
  await previewCommit(repo, `plx preview: ${site.rel} reads ${localPreset} from the local checkout`);
8524
8685
  await ctx.stage(run("copier", ["update", "--skip-answered", "--trust", "--defaults", "--skip-tasks", "--vcs-ref", "main"], {
8525
8686
  cwd: target
@@ -8538,7 +8699,7 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8538
8699
  let conflict = hasUnmergedEntries(status);
8539
8700
  const changed = (await ctx.read("git", ["diff", "--name-only"], { cwd: repo })).split("\n").filter(Boolean);
8540
8701
  for (const f of changed) {
8541
- const text = await ctx.readFile(join7(repo, f)).catch(() => "");
8702
+ const text = await ctx.readFile(join8(repo, f)).catch(() => "");
8542
8703
  if (hasConflictMarkers(text))
8543
8704
  conflict = true;
8544
8705
  }
@@ -8556,17 +8717,17 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8556
8717
  await previewCommit(repo, `plx preview: ${site.rel} updated`);
8557
8718
  }
8558
8719
  async function syncSiteMarker(ctx, site, target) {
8559
- const marker = await ctx.readFile(join7(target, MARKER_FILE)).catch(() => void 0);
8720
+ const marker = await ctx.readFile(join8(target, MARKER_FILE)).catch(() => void 0);
8560
8721
  if (marker === void 0)
8561
8722
  return;
8562
- const { commit } = parseAnswers(await ctx.readFile(join7(target, ANSWERS_FILE)));
8723
+ const { commit } = parseAnswers(await ctx.readFile(join8(target, ANSWERS_FILE)));
8563
8724
  if (!commit)
8564
8725
  return;
8565
8726
  const synced = syncMarker(marker, commit);
8566
8727
  if (synced === marker)
8567
8728
  return;
8568
8729
  ctx.say(` marker: plexus \u2192 ${commit} in ${site.rel}/${MARKER_FILE}`);
8569
- const step = write(join7(target, MARKER_FILE), synced);
8730
+ const step = write(join8(target, MARKER_FILE), synced);
8570
8731
  if (ctx.dryRun)
8571
8732
  await ctx.stage(step);
8572
8733
  else
@@ -8575,7 +8736,7 @@ async function syncSiteMarker(ctx, site, target) {
8575
8736
  async function refreshLockfiles(ctx, consumers, summary, clones) {
8576
8737
  const manifests = ["*package.json", "pnpm-workspace.yaml"];
8577
8738
  for (const c of consumers) {
8578
- if (await ctx.readFile(join7(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)
8579
8740
  continue;
8580
8741
  const repo = ctx.dryRun ? clones.get(c.path) : c.path;
8581
8742
  if (!repo)
@@ -8595,7 +8756,7 @@ async function refreshLockfiles(ctx, consumers, summary, clones) {
8595
8756
  }
8596
8757
  async function bumpConsumerRefs(ctx, consumers, train) {
8597
8758
  for (const c of consumers) {
8598
- const dir = join7(c.path, ".github", "workflows");
8759
+ const dir = join8(c.path, ".github", "workflows");
8599
8760
  const entries = await ctx.readDir(dir).catch(() => []);
8600
8761
  const files = entries.filter((e) => !e.directory && /\.ya?ml$/.test(e.name)).map((e) => e.name).sort();
8601
8762
  if (files.length === 0)
@@ -8605,14 +8766,14 @@ async function bumpConsumerRefs(ctx, consumers, train) {
8605
8766
  continue;
8606
8767
  }
8607
8768
  for (const f of files) {
8608
- const text = await ctx.readFile(join7(dir, f));
8769
+ const text = await ctx.readFile(join8(dir, f));
8609
8770
  if (!text.includes(WORKFLOW_REF))
8610
8771
  continue;
8611
8772
  const bumped = bumpWorkflowRefs(text, train);
8612
8773
  if (bumped === text)
8613
8774
  continue;
8614
8775
  ctx.say(`\u2192 ${c.rel}: ${f} ci-cd refs \u2192 @${train}`);
8615
- await ctx.apply([write(join7(dir, f), bumped)]);
8776
+ await ctx.apply([write(join8(dir, f), bumped)]);
8616
8777
  }
8617
8778
  }
8618
8779
  }
@@ -8623,7 +8784,7 @@ var detail = (error) => {
8623
8784
  };
8624
8785
 
8625
8786
  // ../lib/dist/requirements.js
8626
- import { join as join8 } from "node:path";
8787
+ import { join as join9 } from "node:path";
8627
8788
  var KEYWORD = /MUST|SHOULD|MAY/;
8628
8789
  var hasKeyword = (line) => KEYWORD.test(line.replace(/MUSTs|SHOULDs|MAYs/g, ""));
8629
8790
  var list = (value) => value.replace(/^\s*\[/, "").replace(/\].*$/, "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -8716,7 +8877,7 @@ var MAP_PAGE = "essentials/standard.md";
8716
8877
  async function modulePages(ctx, compendium) {
8717
8878
  const pages = [];
8718
8879
  for (const dir of Object.keys(CATALOG_DIRS)) {
8719
- const entries = await ctx.readDir(join8(compendium, dir));
8880
+ const entries = await ctx.readDir(join9(compendium, dir));
8720
8881
  for (const e of entries.filter((e2) => !e2.directory && e2.name.endsWith(".md") && e2.name !== "index.md").sort())
8721
8882
  pages.push(`${dir}/${e.name}`);
8722
8883
  }
@@ -8724,11 +8885,11 @@ async function modulePages(ctx, compendium) {
8724
8885
  }
8725
8886
  async function checkCompendium(ctx, compendium) {
8726
8887
  const defects = [];
8727
- defects.push(...scanPage(await ctx.readFile(join8(compendium, MAP_PAGE)), MAP_PAGE, "map").defects);
8888
+ defects.push(...scanPage(await ctx.readFile(join9(compendium, MAP_PAGE)), MAP_PAGE, "map").defects);
8728
8889
  const pages = await modulePages(ctx, compendium);
8729
8890
  const scans = /* @__PURE__ */ new Map();
8730
8891
  for (const page of pages) {
8731
- const scan = scanPage(await ctx.readFile(join8(compendium, page)), page, "module");
8892
+ const scan = scanPage(await ctx.readFile(join9(compendium, page)), page, "module");
8732
8893
  defects.push(...scan.defects);
8733
8894
  scans.set(page, scan);
8734
8895
  }
@@ -8775,9 +8936,9 @@ async function checkCompendium(ctx, compendium) {
8775
8936
  }
8776
8937
 
8777
8938
  // ../lib/dist/ship.js
8778
- import { join as join9 } from "node:path";
8939
+ import { join as join10 } from "node:path";
8779
8940
  async function ship(ctx, options) {
8780
- const upstream = join9(options.workspace, UPSTREAM);
8941
+ const upstream = join10(options.workspace, UPSTREAM);
8781
8942
  const propagateOnly = options.propagateOnly ?? false;
8782
8943
  const train = await readTrain(ctx, upstream);
8783
8944
  if (!propagateOnly)
@@ -8828,7 +8989,7 @@ async function planBookkeeping(ctx, train, next, today2) {
8828
8989
  return r;
8829
8990
  };
8830
8991
  const edit = async (r, rel, what, transform) => {
8831
- const path3 = join9(r.path, rel);
8992
+ const path3 = join10(r.path, rel);
8832
8993
  const text = await ctx.readFile(path3).catch(() => void 0);
8833
8994
  if (text === void 0)
8834
8995
  return;
@@ -8849,7 +9010,7 @@ async function planBookkeeping(ctx, train, next, today2) {
8849
9010
  const comp = repo("compendium");
8850
9011
  await edit(comp, MAP_PAGE, `version \u2192 ${next}, timestamp \u2192 ${today2}`, (t) => stampStandard(t, next, today2));
8851
9012
  for (const page of await modulePages(ctx, comp.path)) {
8852
- const text = await ctx.readFile(join9(comp.path, page));
9013
+ const text = await ctx.readFile(join10(comp.path, page));
8853
9014
  if (frontmatterField(text, "maturity") !== "standard")
8854
9015
  continue;
8855
9016
  const previous = comp.tag ? await ctx.read("git", ["show", `${comp.tag}:${page}`], { cwd: comp.path }).catch(() => "") : "";
@@ -9812,13 +9973,13 @@ import { AsyncResource as AsyncResource3 } from "node:async_hooks";
9812
9973
 
9813
9974
  // ../../node_modules/.pnpm/@inquirer+type@4.2.0_@types+node@26.6.5/node_modules/@inquirer/type/dist/utils.js
9814
9975
  function withResolver() {
9815
- let resolve9;
9976
+ let resolve10;
9816
9977
  let reject;
9817
9978
  const promise = new Promise((res, rej) => {
9818
- resolve9 = res;
9979
+ resolve10 = res;
9819
9980
  reject = rej;
9820
9981
  });
9821
- return { promise, resolve: resolve9, reject };
9982
+ return { promise, resolve: resolve10, reject };
9822
9983
  }
9823
9984
 
9824
9985
  // ../../node_modules/.pnpm/@inquirer+core@12.0.5_@types+node@26.6.5/node_modules/@inquirer/core/dist/lib/create-prompt.js
@@ -10197,7 +10358,7 @@ function createPrompt(view) {
10197
10358
  });
10198
10359
  output.mute();
10199
10360
  const screen = new ScreenManager(rl);
10200
- const { promise, resolve: resolve9, reject } = withResolver();
10361
+ const { promise, resolve: resolve10, reject } = withResolver();
10201
10362
  return withHooks(rl, (cycle) => {
10202
10363
  const clearEffects = AsyncResource3.bind(() => effectScheduler.clearAll());
10203
10364
  const settlePrompt = (settle) => {
@@ -10208,7 +10369,7 @@ function createPrompt(view) {
10208
10369
  reject(error);
10209
10370
  }
10210
10371
  };
10211
- const resolvePrompt = (value) => settlePrompt(() => resolve9(value));
10372
+ const resolvePrompt = (value) => settlePrompt(() => resolve10(value));
10212
10373
  const rejectPrompt = (error) => settlePrompt(() => reject(error));
10213
10374
  const promptPromise = Object.assign(promise.finally(() => {
10214
10375
  cleanups.forEach((cleanup) => cleanup());
@@ -13753,19 +13914,49 @@ function renderChangeset(c) {
13753
13914
  return [`\u2713 ${c.path}`, ...releases.map(([name, bump]) => ` ${name}: ${bump}`)].join("\n");
13754
13915
  }
13755
13916
 
13756
- // src/commands/deploy-targets.ts
13917
+ // src/commands/deploy.ts
13757
13918
  import { resolve as resolve3 } from "node:path";
13758
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) {
13759
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) => {
13760
13951
  const ctx = contextOf2(command);
13761
- const targets = await readDeployTargets(ctx, resolve3(inventory));
13952
+ const targets = await readDeployTargets(ctx, resolve4(inventory));
13762
13953
  ctx.result(targets, (t) => JSON.stringify(t));
13763
13954
  });
13764
13955
  }
13765
13956
 
13766
13957
  // src/commands/doctor.ts
13767
13958
  import { existsSync } from "node:fs";
13768
- import { join as join10, resolve as resolve4 } from "node:path";
13959
+ import { join as join11, resolve as resolve5 } from "node:path";
13769
13960
 
13770
13961
  // src/style.ts
13771
13962
  function style(enabled) {
@@ -13776,15 +13967,15 @@ var colorByDefault = () => process.stdout.isTTY === true && !process.env.NO_COLO
13776
13967
 
13777
13968
  // src/commands/doctor.ts
13778
13969
  var WORKSPACE_COMPENDIUM = "plexus-ms/compendium";
13779
- function register4(program2) {
13970
+ function register5(program2) {
13780
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(
13781
13972
  "--compendium <path>",
13782
13973
  `compendium checkout to read the modules from (default: <dir>/${WORKSPACE_COMPENDIUM})`
13783
13974
  ).option("--color", "colour the report even when piped").option("--no-color", "never colour the report").action(async (dir, opts, command) => {
13784
13975
  const ctx = contextOf2(command);
13785
- const root = resolve4(dir);
13786
- const compendium = resolve4(opts.compendium ?? join10(root, WORKSPACE_COMPENDIUM));
13787
- if (!existsSync(join10(compendium, "contracts")))
13976
+ const root = resolve5(dir);
13977
+ const compendium = resolve5(opts.compendium ?? join11(root, WORKSPACE_COMPENDIUM));
13978
+ if (!existsSync(join11(compendium, "contracts")))
13788
13979
  throw new Error(
13789
13980
  `no compendium at ${compendium}; pass --compendium <path to a checkout of plexus-ms/compendium>`
13790
13981
  );
@@ -13812,11 +14003,11 @@ function renderReport(report, s, showCurrent) {
13812
14003
  }
13813
14004
 
13814
14005
  // src/commands/overview.ts
13815
- import { resolve as resolve5 } from "node:path";
13816
- function register5(program2) {
14006
+ import { resolve as resolve6 } from "node:path";
14007
+ function register6(program2) {
13817
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) => {
13818
14009
  const ctx = contextOf2(command);
13819
- const tree = await overview(ctx, resolve5(dir));
14010
+ const tree = await overview(ctx, resolve6(dir));
13820
14011
  const s = style(opts.color ?? colorByDefault());
13821
14012
  ctx.result(tree, (t) => renderTree(t, s, ctx.verbose >= 1));
13822
14013
  });
@@ -13859,14 +14050,14 @@ function annotate(dir, s, modules) {
13859
14050
  }
13860
14051
 
13861
14052
  // src/commands/propagate.ts
13862
- import { resolve as resolve7 } from "node:path";
14053
+ import { resolve as resolve8 } from "node:path";
13863
14054
 
13864
14055
  // src/commands/ship.ts
13865
- import { resolve as resolve6 } from "node:path";
13866
- function register6(program2) {
14056
+ import { resolve as resolve7 } from "node:path";
14057
+ function register7(program2) {
13867
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) => {
13868
14059
  const ctx = contextOf2(command);
13869
- const result = await ship(ctx, { workspace: resolve6(dir), train: opts.train });
14060
+ const result = await ship(ctx, { workspace: resolve7(dir), train: opts.train });
13870
14061
  ctx.result(result, (r) => renderSummary(r, `plx -y ship --train ${r.train} ${dir}`));
13871
14062
  if (!isClean(result)) process.exitCode = 1;
13872
14063
  });
@@ -13889,23 +14080,23 @@ function renderSummary(s, realCommand) {
13889
14080
  }
13890
14081
 
13891
14082
  // src/commands/propagate.ts
13892
- function register7(program2) {
14083
+ function register8(program2) {
13893
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) => {
13894
14085
  const ctx = contextOf2(command);
13895
- const result = await ship(ctx, { workspace: resolve7(dir), train: opts.train, propagateOnly: true });
14086
+ const result = await ship(ctx, { workspace: resolve8(dir), train: opts.train, propagateOnly: true });
13896
14087
  ctx.result(result, (r) => renderSummary(r, `plx -y propagate --train ${r.train} ${dir}`));
13897
14088
  if (!isClean(result)) process.exitCode = 1;
13898
14089
  });
13899
14090
  }
13900
14091
 
13901
14092
  // src/commands/requirements.ts
13902
- import { join as join11, resolve as resolve8 } from "node:path";
13903
- function register8(program2) {
14093
+ import { join as join12, resolve as resolve9 } from "node:path";
14094
+ function register9(program2) {
13904
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) => {
13905
14096
  const ctx = contextOf2(command);
13906
- const compendium = resolve8(dir);
14097
+ const compendium = resolve9(dir);
13907
14098
  if (opts.extract) {
13908
- ctx.result(extractRequirements(await ctx.readFile(join11(compendium, opts.extract))));
14099
+ ctx.result(extractRequirements(await ctx.readFile(join12(compendium, opts.extract))));
13909
14100
  return;
13910
14101
  }
13911
14102
  const check = await checkCompendium(ctx, compendium);
@@ -13920,13 +14111,14 @@ function renderCheck(c) {
13920
14111
 
13921
14112
  // src/commands/index.ts
13922
14113
  var commands = [
13923
- register5,
13924
- register4,
13925
14114
  register6,
14115
+ register5,
13926
14116
  register7,
13927
14117
  register8,
14118
+ register9,
13928
14119
  register2,
13929
14120
  register,
14121
+ register4,
13930
14122
  register3
13931
14123
  ];
13932
14124
 
@@ -13937,7 +14129,7 @@ function buildProgram() {
13937
14129
  program2.addOption(new Option("-n, --dry-run", "print every effect this run would have, perform none").env("PLX_DRY_RUN")).addOption(
13938
14130
  new Option("-v, --verbose", "say more: once for reads and inputs, twice for command output").argParser((_value, previous) => previous + 1).default(0)
13939
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"));
13940
- for (const register9 of commands) register9(program2);
14132
+ for (const register10 of commands) register10(program2);
13941
14133
  return program2;
13942
14134
  }
13943
14135
  function contextOf2(command) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexus-ms/cli",
3
- "version": "0.5.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",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@inquirer/prompts": "^8.7.4",
27
- "@plexus-ms/lib": "0.5.0",
27
+ "@plexus-ms/lib": "0.6.0",
28
28
  "commander": "^15.0.0"
29
29
  },
30
30
  "devDependencies": {