@plexus-ms/cli 0.5.0 → 0.7.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 +277 -71
  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;
@@ -8133,6 +8126,8 @@ function indent(text) {
8133
8126
  var import_yaml3 = __toESM(require_dist(), 1);
8134
8127
  var ANSWERS_FILE = ".copier-answers.yml";
8135
8128
  var TEMPLATE_FILES = ["copier.yml", "copier.yaml"];
8129
+ var COPIER_VERSION = "9.19.1";
8130
+ var copierVia = (args) => ["exec", `copier@${COPIER_VERSION}`, "--", "copier", ...args];
8136
8131
  function parseAnswers(text) {
8137
8132
  const data = (0, import_yaml3.parse)(text);
8138
8133
  if (data === null || typeof data !== "object" || Array.isArray(data))
@@ -8145,8 +8140,9 @@ function parseAnswers(text) {
8145
8140
  };
8146
8141
  }
8147
8142
 
8148
- // ../lib/dist/deployTargets.js
8149
- var import_yaml4 = __toESM(require_dist(), 1);
8143
+ // ../lib/dist/deploy.js
8144
+ import { randomBytes } from "node:crypto";
8145
+ import { tmpdir } from "node:os";
8150
8146
  import { join as join5 } from "node:path";
8151
8147
 
8152
8148
  // ../lib/dist/train.js
@@ -8289,7 +8285,174 @@ pass the version explicitly (--train ${train.last}) to finish the half-tagged tr
8289
8285
  var UsageError = class extends Error {
8290
8286
  };
8291
8287
 
8288
+ // ../lib/dist/deploy.js
8289
+ var PLACEHOLDER = /\$\{PLEXUS_DEPLOYMENT_IMAGE(:[^}]*)?\}/g;
8290
+ var INCOMING = ".incoming";
8291
+ function renderCompose(text, image, path3) {
8292
+ const has = PLACEHOLDER.test(text);
8293
+ PLACEHOLDER.lastIndex = 0;
8294
+ if (image) {
8295
+ if (!has)
8296
+ throw new UsageError(`${path3} does not reference \${PLEXUS_DEPLOYMENT_IMAGE}; an image ref was given but nothing would use it (\xA7 3 PLX-App)`);
8297
+ return text.replace(PLACEHOLDER, image);
8298
+ }
8299
+ if (has)
8300
+ throw new UsageError(`${path3} references \${PLEXUS_DEPLOYMENT_IMAGE} but no image ref was given`);
8301
+ return text;
8302
+ }
8303
+ var q = (word) => `'${word.replaceAll("'", "'\\''")}'`;
8304
+ var Remote = class {
8305
+ socket = join5(tmpdir(), `plx-deploy-${randomBytes(4).toString("hex")}.sock`);
8306
+ ctx;
8307
+ host;
8308
+ constructor(ctx, host) {
8309
+ this.ctx = ctx;
8310
+ this.host = host;
8311
+ }
8312
+ args(command) {
8313
+ return [
8314
+ "-o",
8315
+ "ControlMaster=auto",
8316
+ "-o",
8317
+ `ControlPath=${this.socket}`,
8318
+ "-o",
8319
+ "ControlPersist=60",
8320
+ "-o",
8321
+ "StrictHostKeyChecking=accept-new",
8322
+ this.host,
8323
+ "--",
8324
+ command
8325
+ ];
8326
+ }
8327
+ /** An effect on the host: narrated, performed unless --dry-run. */
8328
+ exec(command, input) {
8329
+ return this.ctx.apply([run("ssh", this.args(command), input === void 0 ? {} : { input })]);
8330
+ }
8331
+ /**
8332
+ * A read from the host: its output, or undefined under --dry-run, where no
8333
+ * host is reached. A read that is a step of the procedure (`show`) is
8334
+ * printed by the dry run like an effect, so the operator copies it too.
8335
+ */
8336
+ async read(command, show = false) {
8337
+ if (this.ctx.dryRun) {
8338
+ this.ctx.say(`${show ? "[dry-run] $" : "?"} ssh ${this.host} -- ${q(command)}`, show ? 0 : 1);
8339
+ return void 0;
8340
+ }
8341
+ return (await this.ctx.read("ssh", this.args(command))).trimEnd();
8342
+ }
8343
+ /** A check on the host: whether the command succeeds, or undefined under --dry-run. */
8344
+ async test(command, show = false) {
8345
+ try {
8346
+ return await this.read(command, show) === void 0 ? void 0 : true;
8347
+ } catch (error) {
8348
+ if (error instanceof ExecError)
8349
+ return false;
8350
+ throw error;
8351
+ }
8352
+ }
8353
+ /** Close the shared connection; nothing to close when no call was made. */
8354
+ close() {
8355
+ return this.ctx.apply([run("ssh", ["-o", `ControlPath=${this.socket}`, "-O", "exit", this.host])]);
8356
+ }
8357
+ };
8358
+ async function deploy(ctx, options) {
8359
+ const { host, tenant, app } = options;
8360
+ const dir = options.dir ?? ".";
8361
+ const image = options.image || void 0;
8362
+ const appDir = options.appDir || `/opt/stacks/${tenant}/${app}`;
8363
+ const source = options.source || `apps/${app}`;
8364
+ const retries = options.retries ?? 30;
8365
+ let compose;
8366
+ let text = "";
8367
+ for (const candidate of [`${source}/docker/compose.yaml`, `${source}/compose.yaml`]) {
8368
+ try {
8369
+ text = await ctx.readFile(join5(dir, candidate));
8370
+ compose = candidate;
8371
+ break;
8372
+ } catch {
8373
+ }
8374
+ }
8375
+ if (compose === void 0)
8376
+ throw new UsageError(`no compose.yaml under ${source}; run from the app's source repo (\xA7 2 PLX-App)`);
8377
+ const rendered = renderCompose(text, image, compose);
8378
+ ctx.say(`\u2192 deploying ${image ?? "<image pinned in compose.yaml>"}`);
8379
+ ctx.say(` host: ${host}`);
8380
+ ctx.say(` dir: ${appDir}`);
8381
+ ctx.say(` compose: ${compose}`);
8382
+ ctx.say(` health: ${options.healthUrl || "<resolved on host>"}`);
8383
+ const remote = new Remote(ctx, host);
8384
+ const D = q(appDir);
8385
+ const dc = `cd ${D} && docker compose`;
8386
+ const dci = `${dc} --project-directory . -f ${INCOMING}/compose.yaml`;
8387
+ const liveImage = `${dc} ps -q web 2>/dev/null | head -1 | xargs -r docker inspect --format '{{.Config.Image}}' 2>/dev/null || true`;
8388
+ const result = { host, appDir, compose, image, healthy: false, rolledBack: false };
8389
+ let staged = false;
8390
+ try {
8391
+ await remote.exec(`rm -rf ${D}/${INCOMING} && mkdir -p ${D}/${INCOMING} && cat > ${D}/${INCOMING}/compose.yaml`, rendered);
8392
+ staged = true;
8393
+ result.previous = await remote.read(liveImage) || void 0;
8394
+ ctx.say(` previous: ${result.previous ?? (ctx.dryRun ? "<read on host>" : "<none>")}`);
8395
+ await remote.exec(`${dci} pull`);
8396
+ const migrate = await remote.test(`${dci} --profile migrate config --services 2>/dev/null | grep -qx migrate`);
8397
+ if (migrate === void 0)
8398
+ ctx.say(" (if compose.yaml declares a migrate service:)");
8399
+ if (migrate !== false)
8400
+ await remote.exec(`${dci} run --rm migrate </dev/null`);
8401
+ const hadPrev = await remote.test(`test -f ${D}/compose.yaml`);
8402
+ if (hadPrev !== false)
8403
+ await remote.exec(`cd ${D} && cp -p compose.yaml ${INCOMING}/compose.yaml.prev`);
8404
+ await remote.exec(`cd ${D} && mv ${INCOMING}/compose.yaml compose.yaml`);
8405
+ await remote.exec(`${dc} up -d`);
8406
+ const url = options.healthUrl || await resolveHealthUrl(remote, D, dc, ctx);
8407
+ 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`;
8408
+ const started = Date.now();
8409
+ const healthy = await remote.test(poll, true);
8410
+ if (healthy !== false) {
8411
+ result.live = await remote.read(liveImage) || image;
8412
+ result.healthy = true;
8413
+ if (ctx.dryRun)
8414
+ ctx.say(" (on failure: restore the previous compose.yaml, up -d, poll again)");
8415
+ else
8416
+ ctx.done(`${result.live ?? "the app"} is live and healthy`);
8417
+ return result;
8418
+ }
8419
+ ctx.say(`\u2717 healthcheck failed after ${Math.round((Date.now() - started) / 1e3)}s; last lines from the app:`);
8420
+ const logs = await remote.read(`${dc} logs --no-color --tail 50 web 2>&1 || true`);
8421
+ if (logs)
8422
+ ctx.say(logs.replace(/^/gm, " "));
8423
+ const differs = hadPrev && await remote.test(`cd ${D} && ! cmp -s compose.yaml ${INCOMING}/compose.yaml.prev`);
8424
+ if (!differs) {
8425
+ ctx.done("nothing to roll back to (first deploy, or the same run shape re-deployed); manual intervention needed", false);
8426
+ return result;
8427
+ }
8428
+ await remote.exec(`cd ${D} && cp -p ${INCOMING}/compose.yaml.prev compose.yaml`);
8429
+ ctx.say(`\u21A9 rolling back to ${result.previous ?? "the previous compose.yaml"}`);
8430
+ await remote.exec(`${dc} up -d`);
8431
+ result.rolledBack = true;
8432
+ if (await remote.test(poll)) {
8433
+ result.live = result.previous;
8434
+ ctx.done(`rolled back to ${result.previous ?? "the previous compose.yaml"}`);
8435
+ } else {
8436
+ ctx.done("rollback also unhealthy; manual intervention needed", false);
8437
+ }
8438
+ return result;
8439
+ } finally {
8440
+ if (staged)
8441
+ await remote.exec(`rm -rf ${D}/${INCOMING}`).catch(() => void 0);
8442
+ await remote.close().catch(() => void 0);
8443
+ }
8444
+ }
8445
+ async function resolveHealthUrl(remote, D, dc, ctx) {
8446
+ const port = await remote.read(`sed -n 's/^PLEXUS_LOOPBACK_PORT=//p' ${D}/.env 2>/dev/null | tail -1`);
8447
+ 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`);
8448
+ 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"}`;
8449
+ ctx.say(` health: ${url}`);
8450
+ return url;
8451
+ }
8452
+
8292
8453
  // ../lib/dist/deployTargets.js
8454
+ var import_yaml4 = __toESM(require_dist(), 1);
8455
+ import { join as join6 } from "node:path";
8293
8456
  var VARS_DIRS = ["group_vars", "host_vars"];
8294
8457
  function parseInventoryFile(path3, text) {
8295
8458
  const data = (0, import_yaml4.parse)(text);
@@ -8318,7 +8481,7 @@ async function readDeployTargets(ctx, inventory) {
8318
8481
  const files = [];
8319
8482
  let found = false;
8320
8483
  for (const sub of VARS_DIRS) {
8321
- const dir = join5(inventory, sub);
8484
+ const dir = join6(inventory, sub);
8322
8485
  const entries = await ctx.readDir(dir).catch(() => void 0);
8323
8486
  if (entries === void 0)
8324
8487
  continue;
@@ -8326,7 +8489,7 @@ async function readDeployTargets(ctx, inventory) {
8326
8489
  for (const e of entries) {
8327
8490
  if (e.directory || !/\.ya?ml$/.test(e.name))
8328
8491
  continue;
8329
- const path3 = join5(dir, e.name);
8492
+ const path3 = join6(dir, e.name);
8330
8493
  files.push(parseInventoryFile(path3, await ctx.readFile(path3)));
8331
8494
  }
8332
8495
  }
@@ -8356,7 +8519,7 @@ function isNotable(dir) {
8356
8519
  }
8357
8520
 
8358
8521
  // ../lib/dist/overview.js
8359
- import { join as join6 } from "node:path";
8522
+ import { join as join7 } from "node:path";
8360
8523
  var PRUNED = /* @__PURE__ */ new Set([
8361
8524
  "node_modules",
8362
8525
  ".hg",
@@ -8404,7 +8567,7 @@ async function walk(ctx, path3, name, options) {
8404
8567
  }
8405
8568
  const subdirs = entries.filter((e) => e.directory && e.name !== ".git" && !PRUNED.has(e.name)).map((e) => e.name).sort();
8406
8569
  for (const sub of subdirs) {
8407
- const child = await walk(ctx, join6(path3, sub), sub, options);
8570
+ const child = await walk(ctx, join7(path3, sub), sub, options);
8408
8571
  if (isNotable(child) || child.children.length > 0)
8409
8572
  dir.children.push(child);
8410
8573
  }
@@ -8412,7 +8575,7 @@ async function walk(ctx, path3, name, options) {
8412
8575
  }
8413
8576
  async function facet(ctx, dir, file, read) {
8414
8577
  try {
8415
- read(await ctx.readFile(join6(dir.path, file)));
8578
+ read(await ctx.readFile(join7(dir.path, file)));
8416
8579
  } catch (error) {
8417
8580
  dir.problems ??= [];
8418
8581
  dir.problems.push(`${file}: ${error instanceof Error ? error.message : String(error)}`);
@@ -8451,12 +8614,12 @@ async function templateFacet(ctx, cwd) {
8451
8614
  }
8452
8615
 
8453
8616
  // ../lib/dist/propagate.js
8454
- import { join as join7, relative as relative3 } from "node:path";
8617
+ import { join as join8, relative as relative3 } from "node:path";
8455
8618
  var isClean = (s) => s.conflicted.length === 0 && s.failed.length === 0 && s.skipped.length === 0;
8456
8619
  var PREVIEW_BASE = "ship-base";
8457
8620
  async function propagate(ctx, workspace, train) {
8458
8621
  const summary = { train, preview: ctx.dryRun, clean: [], conflicted: [], failed: [], skipped: [] };
8459
- const upstream = join7(workspace, UPSTREAM);
8622
+ const upstream = join8(workspace, UPSTREAM);
8460
8623
  const tree = await overview(ctx, workspace);
8461
8624
  const upstreamDir = tree.children.find((c) => c.path === upstream);
8462
8625
  const presets = TRAIN_REPOS.filter((r) => upstreamDir?.children.some((c) => c.name === r && c.template));
@@ -8473,7 +8636,7 @@ async function propagate(ctx, workspace, train) {
8473
8636
  const cloneOf = async (consumer) => {
8474
8637
  let clone = clones.get(consumer.path);
8475
8638
  if (!clone) {
8476
- clone = join7(scratch ?? "", consumer.rel);
8639
+ clone = join8(scratch ?? "", consumer.rel);
8477
8640
  await ctx.stage(run("git", ["clone", "--quiet", consumer.path, clone]));
8478
8641
  await ctx.stage(run("git", ["tag", PREVIEW_BASE], { cwd: clone }));
8479
8642
  clones.set(consumer.path, clone);
@@ -8500,7 +8663,7 @@ async function propagate(ctx, workspace, train) {
8500
8663
  if (sites.length === 0)
8501
8664
  ctx.say(`\u2022 no consumers of ${presetSource(preset)} found under ${workspace}`);
8502
8665
  for (const [consumer, site] of sites)
8503
- await updateSite(ctx, consumer, site, join7(upstream, preset), summary, cloneOf, previewCommit);
8666
+ await updateSite(ctx, consumer, site, join8(upstream, preset), summary, cloneOf, previewCommit);
8504
8667
  }
8505
8668
  await refreshLockfiles(ctx, active, summary, clones);
8506
8669
  await bumpConsumerRefs(ctx, consumers, train);
@@ -8517,17 +8680,15 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8517
8680
  if (ctx.dryRun) {
8518
8681
  ctx.say(`\u2192 ${site.rel}: copier update (preview from ${localPreset}@main)`);
8519
8682
  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}`)));
8683
+ target = join8(repo, relative3(consumer.path, site.path));
8684
+ const answers = await ctx.readFile(join8(site.path, ANSWERS_FILE));
8685
+ await ctx.stage(write(join8(target, ANSWERS_FILE), answers.replace(/^_src_path: .*$/m, `_src_path: ${localPreset}`)));
8523
8686
  await previewCommit(repo, `plx preview: ${site.rel} reads ${localPreset} from the local checkout`);
8524
- await ctx.stage(run("copier", ["update", "--skip-answered", "--trust", "--defaults", "--skip-tasks", "--vcs-ref", "main"], {
8525
- cwd: target
8526
- }));
8687
+ await ctx.stage(run("mise", copierVia(["update", "--skip-answered", "--trust", "--defaults", "--skip-tasks", "--vcs-ref", "main"]), { cwd: target }));
8527
8688
  } else {
8528
8689
  ctx.say(`\u2192 ${site.rel}: copier update`);
8529
8690
  const args = ["update", "--skip-answered", "--trust", ...ctx.yes ? ["--defaults"] : []];
8530
- await ctx.apply([run("copier", args, { cwd: target, interactive: true })]);
8691
+ await ctx.apply([run("mise", copierVia(args), { cwd: target, interactive: true })]);
8531
8692
  }
8532
8693
  } catch (error) {
8533
8694
  ctx.say(` \u2717 FAILED \u2014 copier update exited nonzero${detail(error)}`);
@@ -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
  }
@@ -8774,11 +8935,25 @@ async function checkCompendium(ctx, compendium) {
8774
8935
  };
8775
8936
  }
8776
8937
 
8938
+ // ../lib/dist/self.js
8939
+ var SELF_PACKAGE = "@plexus-ms/cli";
8940
+ async function preflightSelf(ctx, running) {
8941
+ const latest = (await ctx.read("npm", ["view", SELF_PACKAGE, "version"]).catch(() => "")).trim();
8942
+ if (!latest) {
8943
+ ctx.say(`\u26A0 could not ask the registry for the newest ${SELF_PACKAGE}; going on with plx ${running}`);
8944
+ return;
8945
+ }
8946
+ if (compareTrains(running, latest) < 0)
8947
+ throw new Error(`plx ${running} is behind ${latest}, the newest release; update it first: mise cache clear && mise up npm:${SELF_PACKAGE}`);
8948
+ }
8949
+
8777
8950
  // ../lib/dist/ship.js
8778
- import { join as join9 } from "node:path";
8951
+ import { join as join10 } from "node:path";
8779
8952
  async function ship(ctx, options) {
8780
- const upstream = join9(options.workspace, UPSTREAM);
8953
+ const upstream = join10(options.workspace, UPSTREAM);
8781
8954
  const propagateOnly = options.propagateOnly ?? false;
8955
+ if (options.plx)
8956
+ await preflightSelf(ctx, options.plx);
8782
8957
  const train = await readTrain(ctx, upstream);
8783
8958
  if (!propagateOnly)
8784
8959
  await preflightRelease(ctx, train);
@@ -8828,7 +9003,7 @@ async function planBookkeeping(ctx, train, next, today2) {
8828
9003
  return r;
8829
9004
  };
8830
9005
  const edit = async (r, rel, what, transform) => {
8831
- const path3 = join9(r.path, rel);
9006
+ const path3 = join10(r.path, rel);
8832
9007
  const text = await ctx.readFile(path3).catch(() => void 0);
8833
9008
  if (text === void 0)
8834
9009
  return;
@@ -8849,7 +9024,7 @@ async function planBookkeeping(ctx, train, next, today2) {
8849
9024
  const comp = repo("compendium");
8850
9025
  await edit(comp, MAP_PAGE, `version \u2192 ${next}, timestamp \u2192 ${today2}`, (t) => stampStandard(t, next, today2));
8851
9026
  for (const page of await modulePages(ctx, comp.path)) {
8852
- const text = await ctx.readFile(join9(comp.path, page));
9027
+ const text = await ctx.readFile(join10(comp.path, page));
8853
9028
  if (frontmatterField(text, "maturity") !== "standard")
8854
9029
  continue;
8855
9030
  const previous = comp.tag ? await ctx.read("git", ["show", `${comp.tag}:${page}`], { cwd: comp.path }).catch(() => "") : "";
@@ -9812,13 +9987,13 @@ import { AsyncResource as AsyncResource3 } from "node:async_hooks";
9812
9987
 
9813
9988
  // ../../node_modules/.pnpm/@inquirer+type@4.2.0_@types+node@26.6.5/node_modules/@inquirer/type/dist/utils.js
9814
9989
  function withResolver() {
9815
- let resolve9;
9990
+ let resolve10;
9816
9991
  let reject;
9817
9992
  const promise = new Promise((res, rej) => {
9818
- resolve9 = res;
9993
+ resolve10 = res;
9819
9994
  reject = rej;
9820
9995
  });
9821
- return { promise, resolve: resolve9, reject };
9996
+ return { promise, resolve: resolve10, reject };
9822
9997
  }
9823
9998
 
9824
9999
  // ../../node_modules/.pnpm/@inquirer+core@12.0.5_@types+node@26.6.5/node_modules/@inquirer/core/dist/lib/create-prompt.js
@@ -10197,7 +10372,7 @@ function createPrompt(view) {
10197
10372
  });
10198
10373
  output.mute();
10199
10374
  const screen = new ScreenManager(rl);
10200
- const { promise, resolve: resolve9, reject } = withResolver();
10375
+ const { promise, resolve: resolve10, reject } = withResolver();
10201
10376
  return withHooks(rl, (cycle) => {
10202
10377
  const clearEffects = AsyncResource3.bind(() => effectScheduler.clearAll());
10203
10378
  const settlePrompt = (settle) => {
@@ -10208,7 +10383,7 @@ function createPrompt(view) {
10208
10383
  reject(error);
10209
10384
  }
10210
10385
  };
10211
- const resolvePrompt = (value) => settlePrompt(() => resolve9(value));
10386
+ const resolvePrompt = (value) => settlePrompt(() => resolve10(value));
10212
10387
  const rejectPrompt = (error) => settlePrompt(() => reject(error));
10213
10388
  const promptPromise = Object.assign(promise.finally(() => {
10214
10389
  cleanups.forEach((cleanup) => cleanup());
@@ -13753,19 +13928,49 @@ function renderChangeset(c) {
13753
13928
  return [`\u2713 ${c.path}`, ...releases.map(([name, bump]) => ` ${name}: ${bump}`)].join("\n");
13754
13929
  }
13755
13930
 
13756
- // src/commands/deploy-targets.ts
13931
+ // src/commands/deploy.ts
13757
13932
  import { resolve as resolve3 } from "node:path";
13758
13933
  function register3(program2) {
13934
+ program2.command("deploy").description(
13935
+ "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"
13936
+ ).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(
13937
+ "--health-url <url>",
13938
+ "readiness URL polled on the host (default: /healthz, or the plexus.healthz label, on the loopback port)"
13939
+ ).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) => {
13940
+ const ctx = contextOf2(command);
13941
+ const result = await deploy(ctx, {
13942
+ host,
13943
+ tenant,
13944
+ app,
13945
+ image: opts.image,
13946
+ healthUrl: opts.healthUrl,
13947
+ retries: Number(opts.retries),
13948
+ appDir: opts.appDir,
13949
+ source: opts.source,
13950
+ dir: resolve3(opts.dir)
13951
+ });
13952
+ ctx.result(result, renderDeploy);
13953
+ if (!result.healthy) process.exitCode = 1;
13954
+ });
13955
+ }
13956
+ function renderDeploy(d) {
13957
+ if (d.healthy) return d.live ?? "";
13958
+ return d.rolledBack ? `rolled back to ${d.live ?? "the previous compose.yaml"}` : "not healthy";
13959
+ }
13960
+
13961
+ // src/commands/deploy-targets.ts
13962
+ import { resolve as resolve4 } from "node:path";
13963
+ function register4(program2) {
13759
13964
  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
13965
  const ctx = contextOf2(command);
13761
- const targets = await readDeployTargets(ctx, resolve3(inventory));
13966
+ const targets = await readDeployTargets(ctx, resolve4(inventory));
13762
13967
  ctx.result(targets, (t) => JSON.stringify(t));
13763
13968
  });
13764
13969
  }
13765
13970
 
13766
13971
  // src/commands/doctor.ts
13767
13972
  import { existsSync } from "node:fs";
13768
- import { join as join10, resolve as resolve4 } from "node:path";
13973
+ import { join as join11, resolve as resolve5 } from "node:path";
13769
13974
 
13770
13975
  // src/style.ts
13771
13976
  function style(enabled) {
@@ -13776,15 +13981,15 @@ var colorByDefault = () => process.stdout.isTTY === true && !process.env.NO_COLO
13776
13981
 
13777
13982
  // src/commands/doctor.ts
13778
13983
  var WORKSPACE_COMPENDIUM = "plexus-ms/compendium";
13779
- function register4(program2) {
13984
+ function register5(program2) {
13780
13985
  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
13986
  "--compendium <path>",
13782
13987
  `compendium checkout to read the modules from (default: <dir>/${WORKSPACE_COMPENDIUM})`
13783
13988
  ).option("--color", "colour the report even when piped").option("--no-color", "never colour the report").action(async (dir, opts, command) => {
13784
13989
  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")))
13990
+ const root = resolve5(dir);
13991
+ const compendium = resolve5(opts.compendium ?? join11(root, WORKSPACE_COMPENDIUM));
13992
+ if (!existsSync(join11(compendium, "contracts")))
13788
13993
  throw new Error(
13789
13994
  `no compendium at ${compendium}; pass --compendium <path to a checkout of plexus-ms/compendium>`
13790
13995
  );
@@ -13812,11 +14017,11 @@ function renderReport(report, s, showCurrent) {
13812
14017
  }
13813
14018
 
13814
14019
  // src/commands/overview.ts
13815
- import { resolve as resolve5 } from "node:path";
13816
- function register5(program2) {
14020
+ import { resolve as resolve6 } from "node:path";
14021
+ function register6(program2) {
13817
14022
  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
14023
  const ctx = contextOf2(command);
13819
- const tree = await overview(ctx, resolve5(dir));
14024
+ const tree = await overview(ctx, resolve6(dir));
13820
14025
  const s = style(opts.color ?? colorByDefault());
13821
14026
  ctx.result(tree, (t) => renderTree(t, s, ctx.verbose >= 1));
13822
14027
  });
@@ -13859,14 +14064,14 @@ function annotate(dir, s, modules) {
13859
14064
  }
13860
14065
 
13861
14066
  // src/commands/propagate.ts
13862
- import { resolve as resolve7 } from "node:path";
14067
+ import { resolve as resolve8 } from "node:path";
13863
14068
 
13864
14069
  // src/commands/ship.ts
13865
- import { resolve as resolve6 } from "node:path";
13866
- function register6(program2) {
14070
+ import { resolve as resolve7 } from "node:path";
14071
+ function register7(program2) {
13867
14072
  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
14073
  const ctx = contextOf2(command);
13869
- const result = await ship(ctx, { workspace: resolve6(dir), train: opts.train });
14074
+ const result = await ship(ctx, { workspace: resolve7(dir), train: opts.train, plx: version });
13870
14075
  ctx.result(result, (r) => renderSummary(r, `plx -y ship --train ${r.train} ${dir}`));
13871
14076
  if (!isClean(result)) process.exitCode = 1;
13872
14077
  });
@@ -13889,23 +14094,23 @@ function renderSummary(s, realCommand) {
13889
14094
  }
13890
14095
 
13891
14096
  // src/commands/propagate.ts
13892
- function register7(program2) {
14097
+ function register8(program2) {
13893
14098
  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
14099
  const ctx = contextOf2(command);
13895
- const result = await ship(ctx, { workspace: resolve7(dir), train: opts.train, propagateOnly: true });
14100
+ const result = await ship(ctx, { workspace: resolve8(dir), train: opts.train, propagateOnly: true, plx: version });
13896
14101
  ctx.result(result, (r) => renderSummary(r, `plx -y propagate --train ${r.train} ${dir}`));
13897
14102
  if (!isClean(result)) process.exitCode = 1;
13898
14103
  });
13899
14104
  }
13900
14105
 
13901
14106
  // src/commands/requirements.ts
13902
- import { join as join11, resolve as resolve8 } from "node:path";
13903
- function register8(program2) {
14107
+ import { join as join12, resolve as resolve9 } from "node:path";
14108
+ function register9(program2) {
13904
14109
  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
14110
  const ctx = contextOf2(command);
13906
- const compendium = resolve8(dir);
14111
+ const compendium = resolve9(dir);
13907
14112
  if (opts.extract) {
13908
- ctx.result(extractRequirements(await ctx.readFile(join11(compendium, opts.extract))));
14113
+ ctx.result(extractRequirements(await ctx.readFile(join12(compendium, opts.extract))));
13909
14114
  return;
13910
14115
  }
13911
14116
  const check = await checkCompendium(ctx, compendium);
@@ -13920,13 +14125,14 @@ function renderCheck(c) {
13920
14125
 
13921
14126
  // src/commands/index.ts
13922
14127
  var commands = [
13923
- register5,
13924
- register4,
13925
14128
  register6,
14129
+ register5,
13926
14130
  register7,
13927
14131
  register8,
14132
+ register9,
13928
14133
  register2,
13929
14134
  register,
14135
+ register4,
13930
14136
  register3
13931
14137
  ];
13932
14138
 
@@ -13937,7 +14143,7 @@ function buildProgram() {
13937
14143
  program2.addOption(new Option("-n, --dry-run", "print every effect this run would have, perform none").env("PLX_DRY_RUN")).addOption(
13938
14144
  new Option("-v, --verbose", "say more: once for reads and inputs, twice for command output").argParser((_value, previous) => previous + 1).default(0)
13939
14145
  ).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);
14146
+ for (const register10 of commands) register10(program2);
13941
14147
  return program2;
13942
14148
  }
13943
14149
  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.7.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.7.0",
28
28
  "commander": "^15.0.0"
29
29
  },
30
30
  "devDependencies": {