c8ctl-plugin-nano 1.31.0 → 1.32.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 (3) hide show
  1. package/README.md +10 -1
  2. package/c8ctl-plugin.js +209 -27
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -696,7 +696,7 @@ release onto a machine that already has nano installed:
696
696
 
697
697
  ```bash
698
698
  c8ctl nano update # check npm for a newer release and install it
699
- c8ctl nano update --check # only report whether an update is available
699
+ c8ctl nano update --check # report whether an update is available (no install)
700
700
  ```
701
701
 
702
702
  `update` compares the installed plugin version against the latest published on
@@ -706,6 +706,15 @@ with it. It only ever drives npm — it never touches the private upstream sourc
706
706
  so it works for any npm-installed user. After updating, restart any running
707
707
  cluster (`c8ctl nano restart`) so it picks up the new binary.
708
708
 
709
+ Whenever an update is available, `update` (and `update --check`) also prints a
710
+ **changelog of what changed since the installed release** — the per-version
711
+ "Features" / "Bug Fixes" notes pulled from the plugin's public
712
+ [GitHub Releases](https://github.com/jwulf/c8ctl-plugin-nano/releases) (where
713
+ semantic-release records them). This lookup is best-effort and non-blocking: if
714
+ GitHub is unreachable or rate-limited it degrades to a link to the releases page
715
+ and the update proceeds normally. Set `GH_TOKEN` (or `GITHUB_TOKEN`) to raise the
716
+ unauthenticated API rate limit.
717
+
709
718
  If the plugin is running from a local checkout rather than a global npm install,
710
719
  `update` prints the manual command instead of reinstalling in place.
711
720
 
package/c8ctl-plugin.js CHANGED
@@ -5789,6 +5789,183 @@ function compareSemver(a, b) {
5789
5789
  return 0;
5790
5790
  }
5791
5791
 
5792
+ // ---------------------------------------------------------------------------
5793
+ // Update changelog. `update --check` (and the pre-pull path of a real update)
5794
+ // shows what changed between the installed release and latest. The authoritative
5795
+ // source is this plugin's PUBLIC GitHub Releases — semantic-release records the
5796
+ // generated notes there (@semantic-release/github). The committed CHANGELOG.md
5797
+ // is deliberately NOT maintained (the release config dropped the changelog/git
5798
+ // plugins so it never pushes to the protected `main`) and isn't even in the npm
5799
+ // `files`, so it can't be the source. Every lookup here is best-effort and
5800
+ // non-blocking: any failure (offline, rate-limited, private) degrades to a link,
5801
+ // never to a failed `update`.
5802
+ // ---------------------------------------------------------------------------
5803
+
5804
+ /** `owner/repo` parsed from the plugin package's `repository` field (null if absent). */
5805
+ function githubRepoSlug() {
5806
+ try {
5807
+ const pkg = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8'));
5808
+ const raw = pkg?.repository?.url ?? (typeof pkg?.repository === 'string' ? pkg.repository : '');
5809
+ const m = String(raw).match(/github\.com[/:]([^/\s]+\/[^/\s]+?)(?:\.git)?(?:[/#?].*)?$/i);
5810
+ return m ? m[1] : null;
5811
+ } catch {
5812
+ return null;
5813
+ }
5814
+ }
5815
+
5816
+ /**
5817
+ * Keep only the releases strictly newer than `currentVersion` and no newer than
5818
+ * `latestVersion` (when known), newest-first. Pure over its `releases` input (an
5819
+ * array of GitHub release objects) so it is unit-testable without a network call.
5820
+ */
5821
+ function filterReleasesSince(releases, currentVersion, latestVersion) {
5822
+ if (!Array.isArray(releases)) return [];
5823
+ const items = [];
5824
+ for (const r of releases) {
5825
+ if (!r || r.draft || r.prerelease) continue;
5826
+ const tag = r.tag_name || r.name || '';
5827
+ const norm = String(tag).replace(/^v/, '');
5828
+ // Require a plain vX.Y.Z tag: a prerelease/build suffix (e.g. -rc.1, +meta)
5829
+ // must exclude the release rather than be normalised away into the window.
5830
+ if (!/^\d+\.\d+\.\d+$/.test(norm)) continue;
5831
+ const ver = norm;
5832
+ if (!currentVersion || compareSemver(ver, currentVersion) <= 0) continue;
5833
+ if (latestVersion && compareSemver(ver, latestVersion) > 0) continue;
5834
+ items.push({ version: ver, tag, body: r.body || '', url: r.html_url || '' });
5835
+ }
5836
+ items.sort((a, b) => compareSemver(b.version, a.version));
5837
+ return items;
5838
+ }
5839
+
5840
+ /**
5841
+ * Render one semantic-release release body to tight terminal lines: drop the
5842
+ * redundant `# [x.y.z](…)` header, turn `### Features` into a `Features:` label,
5843
+ * flatten `* **scope:** subject ([abc](url))` bullets to `• scope: subject`
5844
+ * (stripping any `([label](url))` commit/PR link groups and inlining any
5845
+ * remaining `[text](url)` as its text). Returns an array of already-indented lines.
5846
+ */
5847
+ function renderReleaseBody(body) {
5848
+ const out = [];
5849
+ for (const line of String(body).split(/\r?\n/)) {
5850
+ if (/^#{1,2}\s+\[?\d+\.\d+\.\d+/.test(line)) continue; // redundant version header
5851
+ const heading = line.match(/^#{2,3}\s+(.*\S)\s*$/);
5852
+ if (heading) {
5853
+ out.push(` ${heading[1]}:`);
5854
+ continue;
5855
+ }
5856
+ const bullet = line.match(/^\s*[*-]\s+(.*)$/);
5857
+ if (bullet) {
5858
+ let text = bullet[1]
5859
+ .replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // ([label](url)) commit/PR link groups
5860
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // inline [text](url) -> text
5861
+ .replace(/\*\*(.*?)\*\*/g, '$1') // **scope** -> scope
5862
+ .replace(/\s+/g, ' ')
5863
+ .trim();
5864
+ if (text) out.push(` \u2022 ${text}`);
5865
+ }
5866
+ }
5867
+ return out;
5868
+ }
5869
+
5870
+ /** Cap on release pages walked, so a repo with a huge history can never hang the walk. */
5871
+ const RELEASE_PAGE_LIMIT = 20;
5872
+
5873
+ /**
5874
+ * True once a page contains a published (non-draft/non-prerelease) vX.Y.Z release
5875
+ * at or below `current`. Because the releases API returns newest-first, everything
5876
+ * after that point is older than the installed version, so the walk can stop.
5877
+ */
5878
+ function reachedInstalledRelease(page, current) {
5879
+ if (!current || !Array.isArray(page)) return false;
5880
+ for (const r of page) {
5881
+ if (!r || r.draft || r.prerelease) continue;
5882
+ const norm = String(r.tag_name || r.name || '').replace(/^v/, '');
5883
+ if (!/^\d+\.\d+\.\d+$/.test(norm)) continue;
5884
+ if (compareSemver(norm, current) <= 0) return true;
5885
+ }
5886
+ return false;
5887
+ }
5888
+
5889
+ /**
5890
+ * Fetch this plugin's GitHub releases newer than `current` (best-effort; null on
5891
+ * any failure). Paginates newest-first, stopping as soon as it reaches the
5892
+ * installed release (or a bounded page cap), so the window stays accurate even
5893
+ * when the installed version is far behind and there are >100 releases since.
5894
+ */
5895
+ async function fetchReleaseNotesSince(slug, current, latest, timeoutMs = 5000) {
5896
+ if (!slug) return null;
5897
+ try {
5898
+ const ctrl = new AbortController();
5899
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
5900
+ const headers = {
5901
+ accept: 'application/vnd.github+json',
5902
+ 'user-agent': 'c8ctl-plugin-nano',
5903
+ 'x-github-api-version': '2022-11-28',
5904
+ };
5905
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
5906
+ if (token) headers.authorization = `Bearer ${token}`;
5907
+ try {
5908
+ const all = [];
5909
+ for (let page = 1; page <= RELEASE_PAGE_LIMIT; page++) {
5910
+ const res = await fetch(
5911
+ `https://api.github.com/repos/${slug}/releases?per_page=100&page=${page}`,
5912
+ { headers, redirect: 'follow', signal: ctrl.signal },
5913
+ );
5914
+ if (!res.ok) return null;
5915
+ const arr = await res.json();
5916
+ if (!Array.isArray(arr) || arr.length === 0) break;
5917
+ all.push(...arr);
5918
+ // Newest-first: once we hit the installed release (or a short final
5919
+ // page), everything remaining is older than `current` — stop early.
5920
+ if (arr.length < 100 || reachedInstalledRelease(arr, current)) break;
5921
+ }
5922
+ return filterReleasesSince(all, current, latest);
5923
+ } finally {
5924
+ clearTimeout(timer);
5925
+ }
5926
+ } catch {
5927
+ return null;
5928
+ }
5929
+ }
5930
+
5931
+ /**
5932
+ * Print the changelog between the installed release and `latest`. Best-effort:
5933
+ * on any fetch failure it prints a single line pointing at the releases page and
5934
+ * returns, so it can never block or fail an `update`.
5935
+ */
5936
+ async function printChangelogSince(_name, current, latest) {
5937
+ const logger = getLogger();
5938
+ const slug = githubRepoSlug();
5939
+ const releasesUrl = slug ? `https://github.com/${slug}/releases` : null;
5940
+ const releases = await fetchReleaseNotesSince(slug, current, latest);
5941
+
5942
+ if (releases === null) {
5943
+ if (releasesUrl) logger.info(`See what changed: ${releasesUrl}`);
5944
+ logger.info('');
5945
+ return;
5946
+ }
5947
+ if (releases.length === 0) {
5948
+ // Nothing resolved between the two (only a build-metadata bump, or a
5949
+ // degraded resolution: current is null / tags don't match vX.Y.Z). Point
5950
+ // at the releases page so the best-effort feature still leaves a trail.
5951
+ if (releasesUrl) logger.info(`See what changed: ${releasesUrl}`);
5952
+ logger.info('');
5953
+ return;
5954
+ }
5955
+
5956
+ logger.info(`What's changed since v${current ?? '?'}:`);
5957
+ logger.info('');
5958
+ for (const rel of releases) {
5959
+ logger.info(` v${rel.version}`);
5960
+ const lines = renderReleaseBody(rel.body);
5961
+ if (lines.length === 0) logger.info(' (no notes)');
5962
+ else for (const l of lines) logger.info(l);
5963
+ logger.info('');
5964
+ }
5965
+ if (releasesUrl) logger.info(`Full release notes: ${releasesUrl}`);
5966
+ logger.info('');
5967
+ }
5968
+
5792
5969
  /**
5793
5970
  * Resolve how npm must be spawned on the given platform. Spawning `npm`
5794
5971
  * directly is not portable: on Windows npm is a `npm.cmd` shim, so bare
@@ -5921,7 +6098,8 @@ function manualUpdateCommand(name, info) {
5921
6098
  return ` npm install -g ${name}@latest`;
5922
6099
  }
5923
6100
 
5924
- function updatePlugin(req) {
6101
+ async function updatePlugin(req) {
6102
+ const logger = getLogger();
5925
6103
  const { name, version: current } = pluginPackage();
5926
6104
 
5927
6105
  // The nano server binary ships with the plugin as its platform package
@@ -5942,44 +6120,47 @@ function updatePlugin(req) {
5942
6120
  const info = pluginInstallInfo();
5943
6121
  const manual = manualUpdateCommand(name, info);
5944
6122
 
5945
- console.log(`Installed: ${name} v${current ?? '?'}${nanoNote}`);
6123
+ logger.info(`Installed: ${name} v${current ?? '?'}${nanoNote}`);
5946
6124
 
5947
6125
  let latest;
5948
6126
  try {
5949
6127
  latest = npmLatestVersion(name);
5950
6128
  } catch (err) {
5951
- console.log(`Could not check npm for updates: ${err.message}`);
5952
- console.log('Pull the latest release manually with:');
5953
- console.log(manual);
6129
+ logger.info(`Could not check npm for updates: ${err.message}`);
6130
+ logger.info('Pull the latest release manually with:');
6131
+ logger.info(manual);
5954
6132
  return;
5955
6133
  }
5956
- console.log(`Latest: ${name} v${latest} (npm)`);
5957
- console.log('');
6134
+ logger.info(`Latest: ${name} v${latest} (npm)`);
6135
+ logger.info('');
5958
6136
 
5959
6137
  if (current && compareSemver(current, latest) >= 0) {
5960
6138
  if (!nanoBin) {
5961
6139
  // Plugin is current but npm never fetched the matching server binary.
5962
- console.log('Plugin is current, but the nano server binary is not installed for this platform.');
5963
- console.log('Provision it by reinstalling the plugin so npm fetches the platform package:');
5964
- console.log(' c8ctl sync plugin');
6140
+ logger.info('Plugin is current, but the nano server binary is not installed for this platform.');
6141
+ logger.info('Provision it by reinstalling the plugin so npm fetches the platform package:');
6142
+ logger.info(' c8ctl sync plugin');
5965
6143
  return;
5966
6144
  }
5967
- console.log('Already on the latest release — nothing to do.');
6145
+ logger.info('Already on the latest release — nothing to do.');
5968
6146
  return;
5969
6147
  }
5970
6148
 
5971
- console.log(`Update available: v${current ?? '?'} -> v${latest}`);
6149
+ logger.info(`Update available: v${current ?? '?'} -> v${latest}`);
6150
+ logger.info('');
6151
+
6152
+ await printChangelogSince(name, current, latest);
5972
6153
 
5973
6154
  if (req.check) {
5974
- console.log('Run `c8ctl nano update` to pull it (or manually):');
5975
- console.log(manual);
6155
+ logger.info('Run `c8ctl nano update` to pull it (or manually):');
6156
+ logger.info(manual);
5976
6157
  return;
5977
6158
  }
5978
6159
 
5979
6160
  if (info.mode === 'local') {
5980
- console.log('This plugin runs from a local checkout, so it cannot self-update in place.');
5981
- console.log('Update it with:');
5982
- console.log(manual);
6161
+ logger.info('This plugin runs from a local checkout, so it cannot self-update in place.');
6162
+ logger.info('Update it with:');
6163
+ logger.info(manual);
5983
6164
  return;
5984
6165
  }
5985
6166
 
@@ -5988,8 +6169,8 @@ function updatePlugin(req) {
5988
6169
  ? ['install', `${name}@${latest}`, '--prefix', info.prefix]
5989
6170
  : ['install', '-g', `${name}@${latest}`];
5990
6171
  const where = info.mode === 'managed' ? 'the c8ctl plugin store' : "npm's global prefix";
5991
- console.log(`Pulling ${name}@${latest} into ${where}...`);
5992
- console.log('');
6172
+ logger.info(`Pulling ${name}@${latest} into ${where}...`);
6173
+ logger.info('');
5993
6174
  try {
5994
6175
  runNpm(installArgs, { stdio: 'inherit' });
5995
6176
  } catch (err) {
@@ -6006,14 +6187,14 @@ function updatePlugin(req) {
6006
6187
  `npm ${installArgs.join(' ')} failed${code}. ${hint}`,
6007
6188
  );
6008
6189
  }
6009
- console.log('');
6190
+ logger.info('');
6010
6191
  if (info.mode === 'managed') {
6011
- console.log(`Updated to v${latest}. The new plugin and bundled nano server load on your next c8ctl command.`);
6192
+ logger.info(`Updated to v${latest}. The new plugin and bundled nano server load on your next c8ctl command.`);
6012
6193
  } else {
6013
- console.log(`Updated to v${latest}.`);
6194
+ logger.info(`Updated to v${latest}.`);
6014
6195
  }
6015
- console.log('Restart any running cluster to use the new server binary:');
6016
- console.log(' c8ctl nano restart');
6196
+ logger.info('Restart any running cluster to use the new server binary:');
6197
+ logger.info(' c8ctl nano restart');
6017
6198
  }
6018
6199
 
6019
6200
  // ---------------------------------------------------------------------------
@@ -7061,6 +7242,7 @@ function parseProcessosRequest(args, flags) {
7061
7242
  export { resolveBinary, findBinary, launcherEnvMarkers };
7062
7243
  export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
7063
7244
  export { buildNpmInvocation };
7245
+ export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
7064
7246
  export {
7065
7247
  webConsoleUrl,
7066
7248
  consoleLinkLabel,
@@ -7190,7 +7372,7 @@ export const metadata = {
7190
7372
  { command: 'c8ctl nano set model-dir <path>', description: 'Set the workspace dir (models + workers)' },
7191
7373
  { command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
7192
7374
  { command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
7193
- { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
7375
+ { command: 'c8ctl nano update --check', description: 'Check for a newer nano release and show the changelog since the installed version (no install)' },
7194
7376
  { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
7195
7377
  { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
7196
7378
  { command: 'c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all', description: 'Hire copilot with a command-line switch (copilot --allow-all)' },
@@ -7247,7 +7429,7 @@ export const commands = {
7247
7429
  purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
7248
7430
  force: { type: 'boolean', description: 'start: stop any existing cluster first' },
7249
7431
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
7250
- check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
7432
+ check: { type: 'boolean', description: 'update: report whether a new release is available (with the changelog since the installed version); do not install' },
7251
7433
  binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
7252
7434
  name: { type: 'string', description: 'work/supervisor add: worker name (auto ‹host›-‹profile›-‹random› if omitted); hire/assign: agent profile name' },
7253
7435
  rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
@@ -7326,7 +7508,7 @@ export const commands = {
7326
7508
  showConfig();
7327
7509
  break;
7328
7510
  case 'update':
7329
- updatePlugin(req);
7511
+ await updatePlugin(req);
7330
7512
  break;
7331
7513
  case 'hire':
7332
7514
  await hireWorker(req, flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.31.0",
3
+ "version": "1.32.0",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.31.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.31.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.31.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.31.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.31.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.31.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.31.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.32.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.32.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.32.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.32.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.32.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.32.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.32.0"
67
67
  }
68
68
  }