@plexus-ms/cli 0.9.0 → 0.10.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 +326 -150
  2. package/package.json +2 -2
package/dist/plx.js CHANGED
@@ -7567,9 +7567,10 @@ function frontmatterField(text, key) {
7567
7567
 
7568
7568
  // ../lib/dist/bookkeeping.js
7569
7569
  var bumpGalaxyVersion = (text, train) => text.replace(/^(version: )\d+\.\d+\.\d+/m, `$1${train}`);
7570
+ var COLLECTION_SOURCE = "plexus-ms/primitives.git";
7570
7571
  function bumpCollectionPin(text, train) {
7571
7572
  const lines = text.split("\n");
7572
- const start = lines.findIndex((l) => l.includes("plexus-ms/platform.git"));
7573
+ const start = lines.findIndex((l) => l.includes(COLLECTION_SOURCE));
7573
7574
  if (start === -1)
7574
7575
  return text;
7575
7576
  for (let i = start; i < lines.length; i++) {
@@ -7581,8 +7582,13 @@ function bumpCollectionPin(text, train) {
7581
7582
  }
7582
7583
  return lines.join("\n");
7583
7584
  }
7584
- var WORKFLOW_REF = "plexus-ms/ci-cd/.github/workflows/";
7585
- var bumpWorkflowRefs = (text, train) => text.replace(/(plexus-ms\/ci-cd\/\.github\/workflows\/[A-Za-z0-9._-]+)@v?[0-9.]+/g, `$1@${train}`);
7585
+ var WORKFLOW_REF = "plexus-ms/primitives/.github/workflows/";
7586
+ var bumpWorkflowRefs = (text, train) => text.replace(/(plexus-ms\/primitives\/\.github\/workflows\/[A-Za-z0-9._-]+)@v?[0-9.]+/g, `$1@${train}`);
7587
+ var bumpManifestVersion = (text, train) => text.replace(/^(\s*"version":\s*")[^"]*(")/m, `$1${train}$2`);
7588
+ var PLX_PIN = "npm:@plexus-ms/cli";
7589
+ var bumpPlxPin = (text, train) => text.replace(/("npm:@plexus-ms\/cli"\s*=\s*(?:\{\s*version\s*=\s*)?")[^"]*(")/g, `$1${train}$2`);
7590
+ var CONFIG_PIN = "@plexus-ms/config";
7591
+ var bumpConfigPin = (text, train) => text.replace(/("@plexus-ms\/config":\s*")(?!workspace:|catalog:)[^"]*(")/g, `$1${train}$2`);
7586
7592
  var stampStandard = (text, train, today2) => setFrontmatterField(setFrontmatterField(text, "version", train), "timestamp", today2);
7587
7593
  var stampChanged = (text, train) => setFrontmatterField(text, "changed", train, { after: "binds" });
7588
7594
  var hasConflictMarkers = (text) => /^<<<<<<< /m.test(text);
@@ -7968,13 +7974,13 @@ async function expand(ctx, root, glob) {
7968
7974
 
7969
7975
  // ../lib/dist/consumers.js
7970
7976
  import { relative } from "node:path";
7971
- function expectedBranch(declaration) {
7977
+ function expectedBranch(declaration, answers = {}) {
7972
7978
  if (!declaration)
7973
7979
  return void 0;
7974
7980
  if (declaration.modules.includes("PLX-Platform"))
7975
7981
  return "main";
7976
7982
  if (declaration.modules.includes("PLX-Repo"))
7977
- return "develop";
7983
+ return answers.publishes_packages === true ? "main" : "develop";
7978
7984
  return void 0;
7979
7985
  }
7980
7986
  var presetSource = (preset) => `gh:plexus-ms/${preset}`;
@@ -7993,11 +7999,12 @@ async function findConsumers(ctx, root, presets) {
7993
7999
  else {
7994
8000
  let consumer = consumers.get(here.path);
7995
8001
  if (!consumer) {
8002
+ const expected = expectedBranch(here.declaration, here.copier?.answers);
7996
8003
  consumer = {
7997
8004
  path: here.path,
7998
8005
  rel: relative(root.path, here.path) || ".",
7999
- branch: here.git?.branch ?? "?",
8000
- expected: expectedBranch(here.declaration),
8006
+ branch: expected ?? here.git?.branch ?? "main",
8007
+ expected,
8001
8008
  sites: []
8002
8009
  };
8003
8010
  consumers.set(here.path, consumer);
@@ -8012,13 +8019,11 @@ async function findConsumers(ctx, root, presets) {
8012
8019
  for (const site of orphans)
8013
8020
  ctx.say(`\u26A0 ${site.rel}: not inside a git repo \u2014 skipped`);
8014
8021
  for (const c of consumers.values()) {
8015
- if (c.expected && c.branch !== c.expected) {
8016
- c.skipped = `on '${c.branch}', expected '${c.expected}'`;
8017
- continue;
8018
- }
8019
- const tracked = (await ctx.read("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: c.path })).trim();
8020
- if (tracked !== "")
8021
- c.skipped = "has uncommitted changes";
8022
+ const origin = (await ctx.read("git", ["remote", "get-url", "origin"], { cwd: c.path }).catch(() => "")).trim();
8023
+ if (origin === "")
8024
+ c.skipped = "no origin remote";
8025
+ else
8026
+ c.origin = origin;
8022
8027
  }
8023
8028
  return [...consumers.values()];
8024
8029
  }
@@ -8222,14 +8227,20 @@ function compareTrains(a, b) {
8222
8227
  }
8223
8228
 
8224
8229
  // ../lib/dist/train.js
8230
+ var PRIMITIVES = "primitives";
8225
8231
  var TRAIN_REPOS = [
8226
- "platform",
8227
- "ci-cd",
8232
+ PRIMITIVES,
8228
8233
  "preset-repo-web",
8229
8234
  "preset-app-nextjs",
8230
8235
  "preset-repo-platform",
8231
8236
  "compendium"
8232
8237
  ];
8238
+ var PHASES = [[PRIMITIVES], TRAIN_REPOS.filter((r) => r !== PRIMITIVES)];
8239
+ var TRAIN_PACKAGES = [
8240
+ { name: "@plexus-ms/cli", dir: "packages/cli" },
8241
+ { name: "@plexus-ms/lib", dir: "packages/lib" },
8242
+ { name: "@plexus-ms/config", dir: "packages/config" }
8243
+ ];
8233
8244
  var UPSTREAM = "plexus-ms";
8234
8245
  var isTrainVersion = (v) => /^\d+\.\d+\.\d+$/.test(v);
8235
8246
  function nextMinor(version2) {
@@ -8265,6 +8276,7 @@ async function readTrain(ctx, upstream) {
8265
8276
  const latest = repos2.map((r) => r.latest);
8266
8277
  return { repos: repos2, last: latestTag([...all]), skew: latest.some((l) => l !== latest[0]) };
8267
8278
  }
8279
+ var phasesOf = (train) => PHASES.map((names) => train.repos.filter((r) => names.includes(r.name)));
8268
8280
  async function preflightRelease(ctx, train) {
8269
8281
  for (const { name, path: path3 } of train.repos) {
8270
8282
  const branch = (await ctx.read("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: path3 })).trim();
@@ -8277,6 +8289,13 @@ async function preflightRelease(ctx, train) {
8277
8289
  throw new Error(`${name} is ${behind} commit(s) behind origin/main \u2014 pull first, or the tag lands on a stale commit`);
8278
8290
  }
8279
8291
  }
8292
+ async function preflightForge(ctx) {
8293
+ try {
8294
+ await ctx.read("gh", ["auth", "status"]);
8295
+ } catch {
8296
+ throw new Error("gh is not logged in (gh auth status failed) \u2014 run gh auth login first");
8297
+ }
8298
+ }
8280
8299
  function resolveVersion(train, explicit, propagateOnly) {
8281
8300
  if (explicit !== void 0 && !isTrainVersion(explicit))
8282
8301
  throw new UsageError(`'${explicit}' is not plain semver (X.Y.Z, no v)`);
@@ -8663,9 +8682,18 @@ async function templateFacet(ctx, cwd) {
8663
8682
  // ../lib/dist/propagate.js
8664
8683
  import { join as join8, relative as relative3 } from "node:path";
8665
8684
  var isClean = (s) => s.conflicted.length === 0 && s.failed.length === 0 && s.skipped.length === 0;
8666
- var PREVIEW_BASE = "ship-base";
8685
+ var BASE_TAG = "ship-base";
8686
+ var trainBranch = (train) => `train/${train}`;
8667
8687
  async function propagate(ctx, workspace, train) {
8668
- const summary = { train, preview: ctx.dryRun, clean: [], conflicted: [], failed: [], skipped: [] };
8688
+ const summary = {
8689
+ train,
8690
+ preview: ctx.dryRun,
8691
+ clean: [],
8692
+ conflicted: [],
8693
+ failed: [],
8694
+ skipped: [],
8695
+ pulls: []
8696
+ };
8669
8697
  const upstream = join8(workspace, UPSTREAM);
8670
8698
  const tree = await overview(ctx, workspace);
8671
8699
  const upstreamDir = tree.children.find((c) => c.path === upstream);
@@ -8677,64 +8705,63 @@ async function propagate(ctx, workspace, train) {
8677
8705
  ctx.say(`\u26A0 ${c.rel}: ${c.skipped} \u2014 skipped (fix it, then re-run propagate)`);
8678
8706
  summary.skipped.push({ path: c.rel, reason: c.skipped });
8679
8707
  }
8680
- const active = consumers.filter((c) => !c.skipped);
8681
- const scratch = ctx.dryRun ? (await ctx.stage(run("mktemp", ["-d"])))?.trim() : void 0;
8682
- const clones = /* @__PURE__ */ new Map();
8683
- const cloneOf = async (consumer) => {
8684
- let clone = clones.get(consumer.path);
8685
- if (!clone) {
8686
- clone = join8(scratch ?? "", consumer.rel);
8687
- await ctx.stage(run("git", ["clone", "--quiet", consumer.path, clone]));
8688
- await ctx.stage(run("git", ["tag", PREVIEW_BASE], { cwd: clone }));
8689
- clones.set(consumer.path, clone);
8690
- }
8691
- return clone;
8692
- };
8693
- const previewCommit = (clone, message) => ctx.stage(run("git", ["add", "-A"], { cwd: clone })).then(() => ctx.stage(run("git", [
8694
- "-c",
8695
- "commit.gpgsign=false",
8696
- "-c",
8697
- "user.name=plx",
8698
- "-c",
8699
- "user.email=plx@localhost",
8700
- "commit",
8701
- "--quiet",
8702
- "--no-verify",
8703
- "--allow-empty",
8704
- "-m",
8705
- message
8706
- ], { cwd: clone })));
8708
+ for (const preset of presets)
8709
+ if (!consumers.some((c) => c.sites.some((s) => s.preset === preset)))
8710
+ ctx.say(`\u2022 no consumers of ${presetSource(preset)} found under ${workspace}`);
8711
+ const scratch = (await ctx.stage(run("mktemp", ["-d"])))?.trim() ?? "";
8707
8712
  try {
8708
- for (const preset of presets) {
8709
- const sites = active.flatMap((c) => c.sites.filter((s) => s.preset === preset).map((s) => [c, s]));
8710
- if (sites.length === 0)
8711
- ctx.say(`\u2022 no consumers of ${presetSource(preset)} found under ${workspace}`);
8712
- for (const [consumer, site] of sites)
8713
- await updateSite(ctx, consumer, site, join8(upstream, preset), summary, cloneOf, previewCommit);
8714
- }
8715
- await refreshLockfiles(ctx, active, summary, clones);
8716
- await bumpConsumerRefs(ctx, consumers, train);
8713
+ for (const consumer of consumers) {
8714
+ if (consumer.skipped || !consumer.origin)
8715
+ continue;
8716
+ const clone = join8(scratch, consumer.rel);
8717
+ ctx.say(`\u2192 ${consumer.rel}: clone ${consumer.origin} at ${consumer.branch}`);
8718
+ try {
8719
+ await ctx.stage(run("git", ["clone", "--quiet", "--branch", consumer.branch, consumer.origin, clone]));
8720
+ await ctx.stage(run("git", ["tag", BASE_TAG], { cwd: clone }));
8721
+ } catch (error) {
8722
+ ctx.say(` \u2717 FAILED \u2014 clone${detail(error)}`);
8723
+ summary.failed.push(consumer.rel);
8724
+ continue;
8725
+ }
8726
+ for (const site of consumer.sites)
8727
+ await updateSite(ctx, consumer, site, clone, upstream, summary);
8728
+ await refreshLockfile(ctx, consumer, clone, summary);
8729
+ await bumpConsumerRefs(ctx, consumer, clone, train);
8730
+ await openPull(ctx, consumer, clone, train, summary);
8731
+ }
8717
8732
  } finally {
8718
8733
  if (scratch)
8719
8734
  await ctx.stage(run("rm", ["-rf", scratch]));
8720
8735
  }
8721
8736
  return summary;
8722
8737
  }
8723
- async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, previewCommit) {
8724
- let repo = consumer.path;
8725
- let target = site.path;
8738
+ var previewCommit = (ctx, clone, message) => ctx.stage(run("git", ["add", "-A"], { cwd: clone })).then(() => ctx.stage(run("git", [
8739
+ "-c",
8740
+ "commit.gpgsign=false",
8741
+ "-c",
8742
+ "user.name=plx",
8743
+ "-c",
8744
+ "user.email=plx@localhost",
8745
+ "commit",
8746
+ "--quiet",
8747
+ "--no-verify",
8748
+ "--allow-empty",
8749
+ "-m",
8750
+ message
8751
+ ], { cwd: clone })));
8752
+ async function updateSite(ctx, consumer, site, clone, upstream, summary) {
8753
+ const target = join8(clone, relative3(consumer.path, site.path));
8754
+ const localPreset = join8(upstream, site.preset);
8726
8755
  try {
8727
8756
  if (ctx.dryRun) {
8728
8757
  ctx.say(`\u2192 ${site.rel}: copier update (preview from ${localPreset}@main)`);
8729
- repo = await cloneOf(consumer);
8730
- target = join8(repo, relative3(consumer.path, site.path));
8731
- const answers = await ctx.readFile(join8(site.path, ANSWERS_FILE));
8758
+ const answers = await ctx.readFile(join8(target, ANSWERS_FILE));
8732
8759
  await ctx.stage(write(join8(target, ANSWERS_FILE), answers.replace(/^_src_path: .*$/m, `_src_path: ${localPreset}`)));
8733
- await previewCommit(repo, `plx preview: ${site.rel} reads ${localPreset} from the local checkout`);
8760
+ await previewCommit(ctx, clone, `plx preview: ${site.rel} reads ${localPreset} from the local checkout`);
8734
8761
  await ctx.stage(run("mise", copierVia(["update", "--skip-answered", "--trust", "--defaults", "--skip-tasks", "--vcs-ref", "main"]), { cwd: target }));
8735
8762
  } else {
8736
8763
  ctx.say(`\u2192 ${site.rel}: copier update`);
8737
- const args = ["update", "--skip-answered", "--trust", ...ctx.yes ? ["--defaults"] : []];
8764
+ const args = ["update", "--skip-answered", "--trust", "--skip-tasks", ...ctx.yes ? ["--defaults"] : []];
8738
8765
  await ctx.apply([run("mise", copierVia(args), { cwd: target, interactive: true })]);
8739
8766
  }
8740
8767
  } catch (error) {
@@ -8742,17 +8769,17 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8742
8769
  summary.failed.push(site.rel);
8743
8770
  return;
8744
8771
  }
8745
- const status = await ctx.read("git", ["status", "--porcelain"], { cwd: repo });
8772
+ const status = await ctx.read("git", ["status", "--porcelain", "--", "."], { cwd: target });
8746
8773
  let conflict = hasUnmergedEntries(status);
8747
- const changed = (await ctx.read("git", ["diff", "--name-only"], { cwd: repo })).split("\n").filter(Boolean);
8774
+ const changed = (await ctx.read("git", ["diff", "--name-only", "--relative", "--", "."], { cwd: target })).split("\n").filter(Boolean);
8748
8775
  for (const f of changed) {
8749
- const text = await ctx.readFile(join8(repo, f)).catch(() => "");
8776
+ const text = await ctx.readFile(join8(target, f)).catch(() => "");
8750
8777
  if (hasConflictMarkers(text))
8751
8778
  conflict = true;
8752
8779
  }
8753
8780
  await syncSiteMarker(ctx, site, target);
8754
8781
  if (conflict) {
8755
- ctx.say(" \u2717 CONFLICTS \u2014 resolve the <<<<<<< markers, then git add:");
8782
+ ctx.say(" \u2717 CONFLICTS \u2014 committed with their <<<<<<< markers, to resolve in the pull request:");
8756
8783
  summary.conflicted.push(site.rel);
8757
8784
  } else {
8758
8785
  ctx.say(" \u2713 merged clean:");
@@ -8761,7 +8788,7 @@ async function updateSite(ctx, consumer, site, localPreset, summary, cloneOf, pr
8761
8788
  for (const line of status.split("\n").filter(Boolean))
8762
8789
  ctx.say(` ${line}`);
8763
8790
  if (ctx.dryRun)
8764
- await previewCommit(repo, `plx preview: ${site.rel} updated`);
8791
+ await previewCommit(ctx, clone, `plx preview: ${site.rel} updated`);
8765
8792
  }
8766
8793
  async function syncSiteMarker(ctx, site, target) {
8767
8794
  const marker = await ctx.readFile(join8(target, MARKER_FILE)).catch(() => void 0);
@@ -8774,55 +8801,129 @@ async function syncSiteMarker(ctx, site, target) {
8774
8801
  if (synced === marker)
8775
8802
  return;
8776
8803
  ctx.say(` marker: plexus \u2192 ${commit} in ${site.rel}/${MARKER_FILE}`);
8777
- const step = write(join8(target, MARKER_FILE), synced);
8778
- if (ctx.dryRun)
8779
- await ctx.stage(step);
8780
- else
8781
- await ctx.apply([step]);
8804
+ await ctx.stage(write(join8(target, MARKER_FILE), synced));
8782
8805
  }
8783
- async function refreshLockfiles(ctx, consumers, summary, clones) {
8806
+ async function refreshLockfile(ctx, consumer, clone, summary) {
8807
+ if (await ctx.readFile(join8(clone, "pnpm-workspace.yaml")).catch(() => void 0) === void 0)
8808
+ return;
8784
8809
  const manifests = ["*package.json", "pnpm-workspace.yaml"];
8785
- for (const c of consumers) {
8786
- if (await ctx.readFile(join8(c.path, "pnpm-workspace.yaml")).catch(() => void 0) === void 0)
8787
- continue;
8788
- const repo = ctx.dryRun ? clones.get(c.path) : c.path;
8789
- if (!repo)
8790
- continue;
8791
- const range = ctx.dryRun ? [PREVIEW_BASE] : [];
8792
- const unchanged = await ctx.read("git", ["diff", "--quiet", ...range, "--", ...manifests], { cwd: repo }).then(() => true).catch(() => false);
8793
- if (unchanged)
8794
- continue;
8795
- ctx.say(`\u2192 ${c.rel}: pnpm install (manifests changed)`);
8796
- try {
8797
- await ctx.apply([run("mise", ["exec", "--", "pnpm", "install", "--reporter=silent"], { cwd: c.path })]);
8798
- } catch (error) {
8799
- ctx.say(` \u2717 pnpm install failed${detail(error)}`);
8800
- summary.failed.push(`${c.rel}/pnpm-lock.yaml`);
8801
- }
8810
+ const unchanged = await ctx.read("git", ["diff", "--quiet", BASE_TAG, "--", ...manifests], { cwd: clone }).then(() => true).catch(() => false);
8811
+ if (unchanged)
8812
+ return;
8813
+ ctx.say(`\u2192 ${consumer.rel}: pnpm install (manifests changed)`);
8814
+ try {
8815
+ await ctx.apply([run("mise", ["exec", "--", "pnpm", "install", "--reporter=silent"], { cwd: clone })]);
8816
+ } catch (error) {
8817
+ ctx.say(` \u2717 pnpm install failed${detail(error)}`);
8818
+ summary.failed.push(`${consumer.rel}/pnpm-lock.yaml`);
8802
8819
  }
8803
8820
  }
8804
- async function bumpConsumerRefs(ctx, consumers, train) {
8805
- for (const c of consumers) {
8806
- const dir = join8(c.path, ".github", "workflows");
8807
- const entries = await ctx.readDir(dir).catch(() => []);
8808
- const files = entries.filter((e) => !e.directory && /\.ya?ml$/.test(e.name)).map((e) => e.name).sort();
8809
- if (files.length === 0)
8821
+ async function bumpConsumerRefs(ctx, consumer, clone, train) {
8822
+ const dir = join8(clone, ".github", "workflows");
8823
+ const entries = await ctx.readDir(dir).catch(() => []);
8824
+ const files = entries.filter((e) => !e.directory && /\.ya?ml$/.test(e.name)).map((e) => e.name).sort();
8825
+ for (const f of files) {
8826
+ const text = await ctx.readFile(join8(dir, f));
8827
+ if (!text.includes(WORKFLOW_REF))
8810
8828
  continue;
8811
- if (c.skipped) {
8812
- ctx.say(`\u26A0 ${c.rel}: workflow refs not bumped (skipped above)`);
8829
+ const bumped = bumpWorkflowRefs(text, train);
8830
+ if (bumped === text)
8813
8831
  continue;
8814
- }
8815
- for (const f of files) {
8816
- const text = await ctx.readFile(join8(dir, f));
8817
- if (!text.includes(WORKFLOW_REF))
8818
- continue;
8819
- const bumped = bumpWorkflowRefs(text, train);
8820
- if (bumped === text)
8821
- continue;
8822
- ctx.say(`\u2192 ${c.rel}: ${f} ci-cd refs \u2192 @${train}`);
8823
- await ctx.apply([write(join8(dir, f), bumped)]);
8824
- }
8832
+ ctx.say(`\u2192 ${consumer.rel}: ${f} workflow refs \u2192 @${train}`);
8833
+ await ctx.stage(write(join8(dir, f), bumped));
8834
+ }
8835
+ }
8836
+ async function openPull(ctx, consumer, clone, train, summary) {
8837
+ const branch = trainBranch(train);
8838
+ if (ctx.dryRun) {
8839
+ ctx.say(` (preview) ${consumer.rel}: would commit, push ${branch} and open a pull request against ${consumer.branch}`);
8840
+ return;
8841
+ }
8842
+ if ((await ctx.read("git", ["status", "--porcelain"], { cwd: clone })).trim() === "") {
8843
+ ctx.say(`\u2022 ${consumer.rel}: nothing changed \u2014 no pull request`);
8844
+ return;
8825
8845
  }
8846
+ const head = `${consumer.origin} ${branch}`;
8847
+ try {
8848
+ await ctx.apply([
8849
+ run("git", ["add", "-A"], { cwd: clone }),
8850
+ run("git", ["commit", "--quiet", "--no-verify", "-m", `chore: train ${train}`], { cwd: clone })
8851
+ ]);
8852
+ await ctx.read("git", ["fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], {
8853
+ cwd: clone
8854
+ }).catch(() => "");
8855
+ await ctx.apply([
8856
+ run("git", ["push", "--quiet", "--force-with-lease", "origin", `HEAD:refs/heads/${branch}`], { cwd: clone })
8857
+ ]);
8858
+ } catch (error) {
8859
+ ctx.say(` \u2717 FAILED \u2014 push to ${head}${detail(error)}`);
8860
+ summary.failed.push(consumer.rel);
8861
+ return;
8862
+ }
8863
+ const existing = (await ctx.read("gh", [
8864
+ "pr",
8865
+ "list",
8866
+ "--head",
8867
+ branch,
8868
+ "--base",
8869
+ consumer.branch,
8870
+ "--state",
8871
+ "open",
8872
+ "--json",
8873
+ "url",
8874
+ "--jq",
8875
+ ".[0].url"
8876
+ ], { cwd: clone }).catch(() => "")).trim();
8877
+ if (existing) {
8878
+ ctx.say(`\u2022 ${consumer.rel}: pull request updated: ${existing}`);
8879
+ summary.pulls.push(existing);
8880
+ return;
8881
+ }
8882
+ const sites = consumer.sites.map((s) => s.rel);
8883
+ const conflicted = sites.filter((s) => summary.conflicted.includes(s));
8884
+ const body2 = [
8885
+ `Train ${train} (\xA7 1.5 PLX): \`copier update\` at ${sites.join(", ")}, the marker, the lockfile where manifests moved, and the workflow refs.`,
8886
+ conflicted.length > 0 ? `Conflicts to resolve, committed with their markers: ${conflicted.join(", ")}.` : "Merged clean at every site.",
8887
+ "Opened by `plx ship`."
8888
+ ].join("\n\n");
8889
+ try {
8890
+ await ctx.apply([
8891
+ run("gh", [
8892
+ "pr",
8893
+ "create",
8894
+ "--base",
8895
+ consumer.branch,
8896
+ "--head",
8897
+ branch,
8898
+ "--title",
8899
+ `chore: train ${train}`,
8900
+ "--body",
8901
+ body2
8902
+ ], { cwd: clone })
8903
+ ]);
8904
+ } catch (error) {
8905
+ ctx.say(` \u2717 FAILED \u2014 pull request${detail(error)}`);
8906
+ summary.failed.push(consumer.rel);
8907
+ return;
8908
+ }
8909
+ const url = (await ctx.read("gh", [
8910
+ "pr",
8911
+ "list",
8912
+ "--head",
8913
+ branch,
8914
+ "--base",
8915
+ consumer.branch,
8916
+ "--state",
8917
+ "open",
8918
+ "--json",
8919
+ "url",
8920
+ "--jq",
8921
+ ".[0].url"
8922
+ ], {
8923
+ cwd: clone
8924
+ }).catch(() => "")).trim();
8925
+ ctx.say(`\u2022 ${consumer.rel}: pull request opened${url ? `: ${url}` : ""}`);
8926
+ summary.pulls.push(url || `${consumer.rel} ${branch}`);
8826
8927
  }
8827
8928
  var detail = (error) => {
8828
8929
  const message = error instanceof Error ? error.message : String(error);
@@ -8996,59 +9097,118 @@ async function preflightSelf(ctx, running) {
8996
9097
 
8997
9098
  // ../lib/dist/ship.js
8998
9099
  import { join as join10 } from "node:path";
9100
+ var DEFAULT_WAIT = { interval: 15e3, timeout: 20 * 6e4 };
8999
9101
  async function ship(ctx, options) {
9000
9102
  const upstream = join10(options.workspace, UPSTREAM);
9001
9103
  const propagateOnly = options.propagateOnly ?? false;
9002
9104
  if (options.plx)
9003
9105
  await preflightSelf(ctx, options.plx);
9004
9106
  const train = await readTrain(ctx, upstream);
9005
- if (!propagateOnly)
9107
+ await preflightForge(ctx);
9108
+ if (!propagateOnly) {
9006
9109
  await preflightRelease(ctx, train);
9110
+ await preflightImmutable(ctx, train);
9111
+ }
9007
9112
  const next = resolveVersion(train, options.train, propagateOnly);
9008
9113
  if (!propagateOnly)
9009
- await release(ctx, train, next, options.today ?? today());
9114
+ await release(ctx, train, next, options.today ?? today(), { ...DEFAULT_WAIT, ...options.wait });
9010
9115
  const summary = await propagate(ctx, options.workspace, next);
9011
9116
  return { ...summary, released: !propagateOnly };
9012
9117
  }
9013
- async function release(ctx, train, next, today2) {
9118
+ async function preflightImmutable(ctx, train) {
9119
+ for (const repo of train.repos) {
9120
+ const newest = (await ctx.read("gh", ["api", "repos/{owner}/{repo}/releases?per_page=1", "--jq", ".[0].immutable"], { cwd: repo.path }).catch(() => "")).trim();
9121
+ if (newest === "false")
9122
+ throw new Error(mutableRelease(`${repo.name}: its newest GitHub release is not immutable`));
9123
+ }
9124
+ }
9125
+ var mutableRelease = (what) => `${what} \u2014 enable immutable releases on the plexus-ms organisation (Settings \u2192 General \u2192 Releases); existing releases stay as they are (never delete one: a deleted immutable release burns its number)`;
9126
+ async function release(ctx, train, next, today2, wait) {
9014
9127
  ctx.say(`\u2192 train ${train.last ?? "none"} \u2192 ${next}`);
9015
9128
  for (const repo of train.repos) {
9016
9129
  if (repo.pending.length === 0)
9017
9130
  ctx.say(` ${repo.name}: no changes since ${repo.latest} \u2014 tag only`);
9018
9131
  else {
9019
9132
  ctx.say(` ${repo.name} (since ${repo.latest ?? "the beginning"}):`);
9020
- for (const line of repo.pending)
9133
+ for (const line of repo.pending.slice(0, PENDING_SHOWN))
9021
9134
  ctx.say(` ${line}`);
9135
+ if (repo.pending.length > PENDING_SHOWN)
9136
+ ctx.say(` \u2026 and ${repo.pending.length - PENDING_SHOWN} more`);
9022
9137
  }
9023
9138
  }
9024
- if (!await ctx.confirm(`tag all ${train.repos.length} repos as ${next} and push?`))
9139
+ if (!await ctx.confirm(`tag all ${train.repos.length} repos as ${next}, release them and push?`))
9025
9140
  throw new Error("aborted");
9141
+ const [one = [], two = []] = phasesOf(train);
9026
9142
  try {
9027
- const plan = await planBookkeeping(ctx, train, next, today2);
9028
- await ctx.apply(plan);
9029
- for (const repo of train.repos) {
9030
- const tagged = await ctx.read("git", ["rev-parse", "-q", "--verify", `refs/tags/${next}`], { cwd: repo.path }).then(() => true).catch(() => false);
9031
- if (tagged)
9032
- ctx.say(`\u2022 ${repo.name}: already tagged ${next}`);
9033
- else
9034
- await ctx.apply([run("git", ["tag", "-a", next, "-m", `train ${next}`], { cwd: repo.path })]);
9035
- await ctx.apply([run("git", ["push", "origin", "main", next], { cwd: repo.path })]);
9036
- }
9143
+ await releasePhase(ctx, train, one, next, today2);
9144
+ await awaitPublished(ctx, next, wait);
9145
+ await releasePhase(ctx, train, two, next, today2);
9037
9146
  } catch (error) {
9038
9147
  const message = error instanceof Error ? error.message : String(error);
9039
9148
  throw new Error(`${message}
9040
- \u2717 ship stopped during the release; re-run with --train ${next} to finish it (tagged repos are skipped)`);
9149
+ \u2717 ship stopped during the release; re-run with --train ${next} to finish it (tagged repos and existing releases are skipped; if the registry already has ${next}, update plx first: it is the same code, built from the commit phase one tagged)`);
9041
9150
  }
9042
9151
  }
9043
- async function planBookkeeping(ctx, train, next, today2) {
9152
+ var PENDING_SHOWN = 20;
9153
+ async function releasePhase(ctx, train, repos2, next, today2) {
9154
+ ctx.say(`\u2192 phase: ${repos2.map((r) => r.name).join(", ")}`);
9155
+ await ctx.apply(await planBookkeeping(ctx, train, repos2, next, today2));
9156
+ for (const repo of repos2)
9157
+ await tagAndRelease(ctx, repo, next);
9158
+ }
9159
+ async function tagAndRelease(ctx, repo, next) {
9160
+ const tagged = await ctx.read("git", ["rev-parse", "-q", "--verify", `refs/tags/${next}`], { cwd: repo.path }).then(() => true).catch(() => false);
9161
+ if (tagged)
9162
+ ctx.say(`\u2022 ${repo.name}: already tagged ${next}`);
9163
+ else
9164
+ await ctx.apply([run("git", ["tag", "-a", next, "-m", `train ${next}`], { cwd: repo.path })]);
9165
+ await ctx.apply([run("git", ["push", "origin", "main", next], { cwd: repo.path })]);
9166
+ const released = await ctx.read("gh", ["release", "view", next], { cwd: repo.path }).then(() => true).catch(() => false);
9167
+ if (released) {
9168
+ ctx.say(`\u2022 ${repo.name}: release ${next} exists`);
9169
+ return;
9170
+ }
9171
+ await ctx.apply([
9172
+ run("gh", ["release", "create", next, "--verify-tag", "--generate-notes", "--title", next], { cwd: repo.path })
9173
+ ]);
9174
+ if (ctx.dryRun)
9175
+ return;
9176
+ const immutable = (await ctx.read("gh", ["api", `repos/{owner}/{repo}/releases/tags/${next}`, "--jq", ".immutable"], {
9177
+ cwd: repo.path
9178
+ })).trim();
9179
+ if (immutable !== "true")
9180
+ throw new Error(mutableRelease(`${repo.name}: release ${next} was created mutable, and its tag can still move`));
9181
+ }
9182
+ async function awaitPublished(ctx, next, wait) {
9183
+ const names = TRAIN_PACKAGES.map((p) => p.name);
9184
+ const list2 = names.map((n) => `${n}@${next}`).join(", ");
9185
+ if (ctx.dryRun) {
9186
+ ctx.say(`\u23F3 [dry-run] would wait for ${list2} on npm (${PRIMITIVES}' release workflow publishes them)`);
9187
+ return;
9188
+ }
9189
+ ctx.say(`\u23F3 waiting for ${list2} on npm (${PRIMITIVES}' release workflow publishes them)`);
9190
+ const started = Date.now();
9191
+ let missing = [...names];
9192
+ for (; ; ) {
9193
+ const still = [];
9194
+ for (const name of missing) {
9195
+ const seen = (await ctx.read("npm", ["view", `${name}@${next}`, "version"]).catch(() => "")).trim();
9196
+ if (seen !== next)
9197
+ still.push(name);
9198
+ }
9199
+ missing = still;
9200
+ if (missing.length === 0)
9201
+ break;
9202
+ if (Date.now() - started >= wait.timeout)
9203
+ throw new Error(`${missing.join(", ")} not on npm at ${next} after ${Math.round(wait.timeout / 6e4)} min \u2014 check the release workflow (gh run list -R plexus-ms/${PRIMITIVES})`);
9204
+ await new Promise((resolve10) => setTimeout(resolve10, wait.interval));
9205
+ }
9206
+ ctx.say(` \u2713 ${list2} on npm`);
9207
+ }
9208
+ async function planBookkeeping(ctx, train, repos2, next, today2) {
9044
9209
  const plan = [];
9045
9210
  const touched = /* @__PURE__ */ new Set();
9046
- const repo = (name) => {
9047
- const r = train.repos.find((r2) => r2.name === name);
9048
- if (!r)
9049
- throw new Error(`${name} is not a train repo`);
9050
- return r;
9051
- };
9211
+ const repo = (name) => repos2.find((r) => r.name === name);
9052
9212
  const edit = async (r, rel, what, transform) => {
9053
9213
  const path3 = join10(r.path, rel);
9054
9214
  const text = await ctx.readFile(path3).catch(() => void 0);
@@ -9061,23 +9221,36 @@ async function planBookkeeping(ctx, train, next, today2) {
9061
9221
  plan.push(write(path3, edited));
9062
9222
  touched.add(r);
9063
9223
  };
9064
- await edit(repo("platform"), "ansible/galaxy.yml", `version \u2192 ${next}`, (t) => bumpGalaxyVersion(t, next));
9065
- await edit(repo("preset-repo-platform"), "template/requirements.yml", `plexus.platform pin \u2192 ${next}`, (t) => bumpCollectionPin(t, next));
9066
- for (const r of train.repos) {
9067
- const files = await ctx.read("git", ["grep", "-l", WORKFLOW_REF, "--", "*.yml", "*.yaml", "*.jinja"], { cwd: r.path }).catch(() => "");
9068
- for (const f of files.split("\n").filter(Boolean))
9069
- await edit(r, f, `ci-cd refs \u2192 @${next}`, (t) => bumpWorkflowRefs(t, next));
9224
+ const grep = async (r, needle, pathspecs) => (await ctx.read("git", ["grep", "-l", needle, "--", ...pathspecs], { cwd: r.path }).catch(() => "")).split("\n").filter(Boolean);
9225
+ const primitives = repo(PRIMITIVES);
9226
+ if (primitives) {
9227
+ await edit(primitives, "ansible/galaxy.yml", `version \u2192 ${next}`, (t) => bumpGalaxyVersion(t, next));
9228
+ for (const p of TRAIN_PACKAGES)
9229
+ await edit(primitives, `${p.dir}/package.json`, `${p.name} \u2192 ${next}`, (t) => bumpManifestVersion(t, next));
9230
+ }
9231
+ const platformPreset = repo("preset-repo-platform");
9232
+ if (platformPreset)
9233
+ await edit(platformPreset, "template/requirements.yml", `plexus.platform pin \u2192 ${next}`, (t) => bumpCollectionPin(t, next));
9234
+ for (const r of repos2) {
9235
+ for (const f of await grep(r, WORKFLOW_REF, ["*.yml", "*.yaml", "*.jinja"]))
9236
+ await edit(r, f, `workflow refs \u2192 @${next}`, (t) => bumpWorkflowRefs(t, next));
9237
+ for (const f of await grep(r, PLX_PIN, ["*.toml", "*.toml.jinja"]))
9238
+ await edit(r, f, `plx pin \u2192 ${next}`, (t) => bumpPlxPin(t, next));
9239
+ for (const f of await grep(r, `"${CONFIG_PIN}"`, ["*package.json", "*package.json.jinja"]))
9240
+ await edit(r, f, `${CONFIG_PIN} pin \u2192 ${next}`, (t) => bumpConfigPin(t, next));
9070
9241
  }
9071
9242
  const comp = repo("compendium");
9072
- await edit(comp, MAP_PAGE, `version \u2192 ${next}, timestamp \u2192 ${today2}`, (t) => stampStandard(t, next, today2));
9073
- for (const page of await modulePages(ctx, comp.path)) {
9074
- const text = await ctx.readFile(join10(comp.path, page));
9075
- if (frontmatterField(text, "maturity") !== "standard")
9076
- continue;
9077
- const previous = comp.tag ? await ctx.read("git", ["show", `${comp.tag}:${page}`], { cwd: comp.path }).catch(() => "") : "";
9078
- if (extractRequirements(text).join("\n") === extractRequirements(previous).join("\n"))
9079
- continue;
9080
- await edit(comp, page, `requirements changed since ${comp.latest ?? "the beginning"} \u2192 changed: ${next}`, (t) => stampChanged(t, next));
9243
+ if (comp) {
9244
+ await edit(comp, MAP_PAGE, `version \u2192 ${next}, timestamp \u2192 ${today2}`, (t) => stampStandard(t, next, today2));
9245
+ for (const page of await modulePages(ctx, comp.path)) {
9246
+ const text = await ctx.readFile(join10(comp.path, page));
9247
+ if (frontmatterField(text, "maturity") !== "standard")
9248
+ continue;
9249
+ const previous = comp.tag ? await ctx.read("git", ["show", `${comp.tag}:${page}`], { cwd: comp.path }).catch(() => "") : "";
9250
+ if (extractRequirements(text).join("\n") === extractRequirements(previous).join("\n"))
9251
+ continue;
9252
+ await edit(comp, page, `requirements changed since ${comp.latest ?? "the beginning"} \u2192 changed: ${next}`, (t) => stampChanged(t, next));
9253
+ }
9081
9254
  }
9082
9255
  for (const r of train.repos) {
9083
9256
  if (!touched.has(r))
@@ -14119,7 +14292,9 @@ import { resolve as resolve8 } from "node:path";
14119
14292
  // src/commands/ship.ts
14120
14293
  import { resolve as resolve7 } from "node:path";
14121
14294
  function register7(program2) {
14122
- 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) => {
14295
+ program2.command("ship").description(
14296
+ "release the train: tag and release every train repo identically, then open a pull request per consumer"
14297
+ ).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) => {
14123
14298
  const ctx = contextOf2(command);
14124
14299
  const result = await ship(ctx, { workspace: resolve7(dir), train: opts.train, plx: version });
14125
14300
  ctx.result(result, (r) => renderSummary(r, `plx -y ship --train ${r.train} ${dir}`));
@@ -14132,12 +14307,13 @@ function renderSummary(s, realCommand) {
14132
14307
  for (const p of s.conflicted) lines.push(` \u2717 conflicts: ${p}`);
14133
14308
  for (const p of s.failed) lines.push(` \u2717 failed: ${p}`);
14134
14309
  for (const { path: path3, reason } of s.skipped) lines.push(` \u26A0 skipped: ${path3} (${reason})`);
14310
+ for (const url of s.pulls) lines.push(` \u2192 pull: ${url}`);
14135
14311
  const clean = isClean(s);
14136
14312
  if (s.preview) {
14137
14313
  lines.push(" (preview only \u2014 nothing was changed)");
14138
14314
  if (clean) lines.push(`\u2713 dry run clean \u2014 run it for real: ${realCommand}`);
14139
14315
  } else if (clean) {
14140
- lines.push("\u2713 done \u2014 review consumer diffs, commit (web repos on develop, platform repos on main), push");
14316
+ lines.push("\u2713 done \u2014 review and merge the pull requests");
14141
14317
  }
14142
14318
  if (!clean) lines.push("\u2717 needs attention \u2014 see above; fix the consumer, then re-run propagate");
14143
14319
  return lines.join("\n");
@@ -14145,7 +14321,7 @@ function renderSummary(s, realCommand) {
14145
14321
 
14146
14322
  // src/commands/propagate.ts
14147
14323
  function register8(program2) {
14148
- 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) => {
14324
+ program2.command("propagate").description("copier-update every consumer of the presets to the current train, as one pull request per consumer").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) => {
14149
14325
  const ctx = contextOf2(command);
14150
14326
  const result = await ship(ctx, { workspace: resolve8(dir), train: opts.train, propagateOnly: true, plx: version });
14151
14327
  ctx.result(result, (r) => renderSummary(r, `plx -y propagate --train ${r.train} ${dir}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexus-ms/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.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.9.0",
27
+ "@plexus-ms/lib": "0.10.0",
28
28
  "commander": "^15.0.0"
29
29
  },
30
30
  "devDependencies": {