c8ctl-plugin-nano 1.1.0 → 1.2.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 +51 -7
  2. package/c8ctl-plugin.js +492 -23
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -145,6 +145,22 @@ cluster (`c8ctl nano restart`) so it picks up the new binary.
145
145
  If the plugin is running from a local checkout rather than a global npm install,
146
146
  `update` prints the manual command instead of reinstalling in place.
147
147
 
148
+ ### Automatic "update available" notice
149
+
150
+ You don't have to remember to run `update --check`: any `nano` or `processos`
151
+ command also surfaces a one-line notice when a newer release is published. It is
152
+ deliberately unobtrusive:
153
+
154
+ - The registry lookup runs in a **detached background process**, so a command is
155
+ never slowed down — the fresh result is used on the next invocation.
156
+ - npm is queried at most **once per day**, and the notice is shown at most **once
157
+ per day** (state is cached under the plugin's state home in `update-check.json`).
158
+ - The notice prints to **stderr**, so it never corrupts machine-readable stdout,
159
+ and is suppressed when stdout is not a TTY (piped/scripted) or when `CI` is set.
160
+
161
+ To turn it off entirely, set `NANO_NO_UPDATE_NOTIFIER=1` (or the conventional
162
+ `NO_UPDATE_NOTIFIER=1`). The explicit `c8ctl nano update` command is unaffected.
163
+
148
164
  ## Checking status
149
165
 
150
166
  `c8ctl nano status` queries each node's always-on `GET /v2/topology`, which is the
@@ -354,14 +370,29 @@ ProcessOS is the optimization-plane server that analyses a running Nano BPM
354
370
  engine. The plugin can manage a single local ProcessOS instance with the same
355
371
  start/stop/status/logs lifecycle as `nano`.
356
372
 
357
- Unlike the Nano BPM server binary (which is distributed via npm — see below),
358
- **the ProcessOS binary is downloaded manually**: grab the build for your
359
- platform, then point the plugin at it.
373
+ > **ProcessOS is a closed alpha.** The operational commands (`start`, `stop`,
374
+ > `status`, `logs`, `restart`) stay locked with a *"not available yet"* notice
375
+ > until you opt in. Only `set` and `config` work before then. Opt in either by
376
+ > setting the download URL you were given by the Nano BPM team, or by pointing
377
+ > the plugin at a binary you already have.
360
378
 
361
379
  ```bash
362
- # One-time: tell the plugin where the binary is
380
+ # Closed-alpha channel: the plugin downloads + caches the matching binary
381
+ export PROCESSOS_DOWNLOAD_URL=<url you were given>
382
+ c8ctl processos start # fetches processos-<os>-<arch> on first run
383
+
384
+ # …or point the plugin at a binary you already have
363
385
  c8ctl processos set bin ~/Downloads/processos
386
+ c8ctl processos start
387
+ ```
388
+
389
+ `PROCESSOS_DOWNLOAD_URL` is the prefix the release binaries live under (e.g. the
390
+ `…/processos/latest/` bucket URL). The plugin appends the per-platform asset name
391
+ (`processos-darwin-arm64`, `processos-linux-x64`, `processos-win32-x64.exe`, …),
392
+ downloads it to `<stateHome>/bin/`, marks it executable, and runs it. The cached
393
+ download is reused on subsequent starts.
364
394
 
395
+ ```bash
365
396
  # Start ProcessOS against the local Nano BPM engine (http://localhost:8080)
366
397
  c8ctl processos start
367
398
 
@@ -374,6 +405,18 @@ c8ctl processos logs --follow
374
405
  c8ctl processos stop
375
406
  ```
376
407
 
408
+ ### Automatic update notice
409
+
410
+ When you're on the closed-alpha channel (`PROCESSOS_DOWNLOAD_URL` set), the
411
+ plugin checks for newer ProcessOS builds in the background and prints a short
412
+ one-line notice (at most **once per day**) when the published version is newer
413
+ than the one you're running. It compares your installed binary's version against
414
+ the `version.json` the release pipeline publishes next to the binaries, never
415
+ blocks the command (the check runs detached), and is suppressed on
416
+ non-interactive shells, in CI, and when `NO_UPDATE_NOTIFIER` /
417
+ `NANO_NO_UPDATE_NOTIFIER` is set. To update, stop and start ProcessOS again — a
418
+ downloaded binary re-fetches the latest build; a `set bin` binary updates itself.
419
+
377
420
  On a successful `start` the summary leads with the landing page:
378
421
 
379
422
  ```
@@ -423,9 +466,10 @@ c8ctl processos config # show current settings and on-disk path
423
466
  ```
424
467
 
425
468
  The binary is resolved in this order: `--binary` flag → `set bin` →
426
- `$PROCESSOS_BINARY` → a local `processos/target/{release,debug}/processos` build.
427
- Typed settings (`port`, `nano-url`, `data-dir`) always win over generic `env`
428
- passthrough values when launching.
469
+ `$PROCESSOS_BINARY` → a cached download under `<stateHome>/bin/` → a local
470
+ `processos/target/{release,debug}/processos` build a fresh download from
471
+ `PROCESSOS_DOWNLOAD_URL`. Typed settings (`port`, `nano-url`, `data-dir`) always
472
+ win over generic `env` passthrough values when launching.
429
473
 
430
474
  ## Installing
431
475
 
package/c8ctl-plugin.js CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  writeFileSync,
35
35
  rmSync,
36
36
  readdirSync,
37
+ chmodSync,
38
+ renameSync,
37
39
  } from 'node:fs';
38
40
  import { homedir, platform as osPlatform } from 'node:os';
39
41
  import { join, isAbsolute, resolve as resolvePath, dirname } from 'node:path';
@@ -94,6 +96,23 @@ const PROCESSOS_STATE_FILE = 'processos.json';
94
96
  const PROCESSOS_DEFAULT_PORT = 8090;
95
97
  const DEFAULT_NANO_URL = 'http://localhost:8080';
96
98
 
99
+ // Passive update notifier (npm-style): refresh the latest published version
100
+ // from the registry in a detached background process at most once per day, and
101
+ // surface a one-line "update available" notice at most once per day. Never
102
+ // blocks a command and never fails one.
103
+ const UPDATE_CACHE_FILE = 'update-check.json';
104
+ const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
105
+ const UPDATE_NOTIFY_TTL_MS = 24 * 60 * 60 * 1000;
106
+
107
+ // ProcessOS is a closed alpha distributed out-of-band: the binary lives in an
108
+ // S3 bucket whose base URL is handed to enabled users via PROCESSOS_DOWNLOAD_URL.
109
+ // `<base>/processos-<os>-<arch>[.exe]` is the per-platform binary and
110
+ // `<base>/version.json` is the {version,commit,updated} metadata the CI writes
111
+ // next to it (the analogue of npm's latest-version lookup for the nano plugin).
112
+ const PROCESSOS_VERSION_META = 'version.json';
113
+ const PROCESSOS_BINARY_META_FILE = 'processos-binary.json';
114
+ const PROCESSOS_UPDATE_CACHE_FILE = 'processos-update-check.json';
115
+
97
116
  function getLogger() {
98
117
  if (globalThis.c8ctl) {
99
118
  return globalThis.c8ctl.getLogger();
@@ -1221,6 +1240,119 @@ function updatePlugin(req) {
1221
1240
  console.log(' c8ctl nano restart');
1222
1241
  }
1223
1242
 
1243
+ // ---------------------------------------------------------------------------
1244
+ // Passive "update available" notice. Modelled on npm's update-notifier: the
1245
+ // actual registry lookup runs in a detached background process (so a command is
1246
+ // never slowed), and we only print a notice — at most once per day — from a
1247
+ // cached result. The explicit `c8ctl nano update[ --check]` path is unchanged.
1248
+ // ---------------------------------------------------------------------------
1249
+
1250
+ function getUpdateCacheFile() {
1251
+ return join(getStateHome(), UPDATE_CACHE_FILE);
1252
+ }
1253
+
1254
+ function readUpdateCache() {
1255
+ try {
1256
+ return JSON.parse(readFileSync(getUpdateCacheFile(), 'utf8'));
1257
+ } catch {
1258
+ return {};
1259
+ }
1260
+ }
1261
+
1262
+ function writeUpdateCache(obj) {
1263
+ try {
1264
+ mkdirSync(getStateHome(), { recursive: true });
1265
+ writeFileSync(getUpdateCacheFile(), JSON.stringify(obj));
1266
+ } catch {
1267
+ /* a best-effort cache; ignore write failures */
1268
+ }
1269
+ }
1270
+
1271
+ /**
1272
+ * True when the notifier should stay silent: an explicit opt-out, CI, or a
1273
+ * non-interactive stdout (piped/scripted), so we never pollute machine-read
1274
+ * output or nag in automation.
1275
+ */
1276
+ function updateNotifierDisabled() {
1277
+ if (process.env.NANO_NO_UPDATE_NOTIFIER || process.env.NO_UPDATE_NOTIFIER) return true;
1278
+ if (process.env.CI) return true;
1279
+ if (!process.stdout.isTTY) return true;
1280
+ return false;
1281
+ }
1282
+
1283
+ /**
1284
+ * Refresh the cached latest version in the background. Spawns a detached Node
1285
+ * process that runs `npm view <name> version` and writes the result to the
1286
+ * cache file, then exits — the current command does not wait on it, so the
1287
+ * fresh result is used on the *next* invocation.
1288
+ */
1289
+ function spawnUpdateRefresh(name, cacheFile) {
1290
+ const script =
1291
+ 'const{spawnSync}=require("child_process");' +
1292
+ 'const{readFileSync,writeFileSync}=require("fs");' +
1293
+ `let prev={};try{prev=JSON.parse(readFileSync(${JSON.stringify(cacheFile)},"utf8"))}catch{}` +
1294
+ 'const out=Object.assign({},prev,{lastCheck:Date.now()});' +
1295
+ `const r=spawnSync("npm",["view",${JSON.stringify(name)},"version"],{encoding:"utf8"});` +
1296
+ 'if(r.status===0){out.latest=String(r.stdout||"").trim()}' +
1297
+ `try{writeFileSync(${JSON.stringify(cacheFile)},JSON.stringify(out))}catch{}`;
1298
+ try {
1299
+ const child = spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' });
1300
+ child.unref();
1301
+ } catch {
1302
+ /* if we can't spawn, just skip this cycle */
1303
+ }
1304
+ }
1305
+
1306
+ function printUpdateNotice(name, current, latest) {
1307
+ const lines = [
1308
+ '',
1309
+ `╭─ Update available: ${name} v${current} → v${latest}`,
1310
+ '│ A newer nano release (plugin + bundled server) is published on npm.',
1311
+ '│ Install it: c8ctl nano update',
1312
+ `│ Or manually: npm install -g ${name}@latest`,
1313
+ '╰─ Then restart any running cluster: c8ctl nano restart',
1314
+ '',
1315
+ ];
1316
+ // stderr so it never corrupts parseable stdout.
1317
+ for (const l of lines) console.error(l);
1318
+ }
1319
+
1320
+ /**
1321
+ * Best-effort, non-blocking update check run at the end of a command. Triggers
1322
+ * a background registry refresh when the cache is stale, and prints a notice
1323
+ * (at most once per day) when the cached latest version is newer than installed.
1324
+ */
1325
+ function maybeNotifyUpdate(subcommand) {
1326
+ try {
1327
+ if (updateNotifierDisabled()) return;
1328
+ if (subcommand === 'update') return; // the explicit command reports its own state
1329
+ const { name, version: current } = pluginPackage();
1330
+ if (!current || current === '0.0.0-dev') return;
1331
+
1332
+ const cacheFile = getUpdateCacheFile();
1333
+ const cache = readUpdateCache();
1334
+ const now = Date.now();
1335
+
1336
+ if (!cache.lastCheck || now - cache.lastCheck > UPDATE_CHECK_TTL_MS) {
1337
+ try {
1338
+ mkdirSync(getStateHome(), { recursive: true });
1339
+ } catch {
1340
+ /* ignore */
1341
+ }
1342
+ spawnUpdateRefresh(name, cacheFile);
1343
+ }
1344
+
1345
+ const latest = cache.latest;
1346
+ if (!latest || compareSemver(current, latest) >= 0) return;
1347
+ if (cache.lastNotified && now - cache.lastNotified <= UPDATE_NOTIFY_TTL_MS) return;
1348
+
1349
+ printUpdateNotice(name, current, latest);
1350
+ writeUpdateCache({ ...cache, lastNotified: now });
1351
+ } catch {
1352
+ /* the notifier must never break a command */
1353
+ }
1354
+ }
1355
+
1224
1356
  // ---------------------------------------------------------------------------
1225
1357
  // processos — manage a single local ProcessOS instance (the optimization-plane
1226
1358
  // server that analyses a running Nano BPM engine). Unlike nano, the ProcessOS
@@ -1297,15 +1429,17 @@ function clearProcessosState() {
1297
1429
  }
1298
1430
 
1299
1431
  /**
1300
- * Locate the ProcessOS binary. Resolution order:
1432
+ * Locate a ProcessOS binary the user already has, WITHOUT downloading. Order:
1301
1433
  * 1. --binary flag
1302
1434
  * 2. configured path ("processos set bin <path>")
1303
1435
  * 3. PROCESSOS_BINARY env var
1304
- * 4. release build under the nanobpmn repo
1305
- * 5. debug build under the nanobpmn repo
1306
- * The binary is not shipped via npm it is downloaded manually.
1436
+ * 4. a previously auto-downloaded binary cached under the state home
1437
+ * 5. release / debug build under the nanobpmn repo (local dev)
1438
+ * Returns an absolute path, or null when nothing is configured/present. Throws
1439
+ * only when an *explicitly* configured source points at a missing file (so the
1440
+ * user gets an actionable error rather than a silent fallthrough).
1307
1441
  */
1308
- function findProcessosBinary(req) {
1442
+ function findConfiguredProcessosBinary(req) {
1309
1443
  const cfg = readProcessosConfig();
1310
1444
  const sources = [
1311
1445
  { val: req?.binary && String(req.binary), from: '--binary' },
@@ -1321,24 +1455,331 @@ function findProcessosBinary(req) {
1321
1455
  return abs;
1322
1456
  }
1323
1457
 
1324
- const repo = getRepoRoot();
1325
- const name = 'processos';
1326
- const candidates = [
1327
- join(repo, 'processos', 'target', 'release', name),
1328
- join(repo, 'processos', 'target', 'debug', name),
1329
- ];
1330
- for (const c of candidates) {
1331
- if (existsSync(c)) return c;
1458
+ const cached = getProcessosCachedBinaryPath();
1459
+ if (existsSync(cached)) return cached;
1460
+
1461
+ let repo = null;
1462
+ try {
1463
+ repo = getRepoRoot();
1464
+ } catch {
1465
+ repo = null;
1466
+ }
1467
+ if (repo) {
1468
+ const candidates = [
1469
+ join(repo, 'processos', 'target', 'release', 'processos'),
1470
+ join(repo, 'processos', 'target', 'debug', 'processos'),
1471
+ ];
1472
+ for (const c of candidates) {
1473
+ if (existsSync(c)) return c;
1474
+ }
1475
+ }
1476
+ return null;
1477
+ }
1478
+
1479
+ /** The state-home directory that holds an auto-downloaded ProcessOS binary. */
1480
+ function getProcessosBinDir() {
1481
+ return join(getStateHome(), 'bin');
1482
+ }
1483
+
1484
+ function getProcessosCachedBinaryPath() {
1485
+ const name = process.platform === 'win32' ? 'processos.exe' : 'processos';
1486
+ return join(getProcessosBinDir(), name);
1487
+ }
1488
+
1489
+ /** Sidecar recording the version of the auto-downloaded binary (for update checks). */
1490
+ function getProcessosBinaryMetaPath() {
1491
+ return join(getProcessosBinDir(), PROCESSOS_BINARY_META_FILE);
1492
+ }
1493
+
1494
+ function readProcessosBinaryMeta() {
1495
+ try {
1496
+ return JSON.parse(readFileSync(getProcessosBinaryMetaPath(), 'utf8'));
1497
+ } catch {
1498
+ return {};
1499
+ }
1500
+ }
1501
+
1502
+ /**
1503
+ * The S3 asset name for the host platform, matching the names the nanobpmn CI
1504
+ * uploads (`processos-<os>-<arch>`, `.exe` on Windows). Null on an unsupported
1505
+ * platform.
1506
+ */
1507
+ function processosAssetName(platform = process.platform, arch = process.arch) {
1508
+ const map = {
1509
+ 'darwin:arm64': 'processos-darwin-arm64',
1510
+ 'darwin:x64': 'processos-darwin-x64',
1511
+ 'linux:x64': 'processos-linux-x64',
1512
+ 'linux:arm64': 'processos-linux-arm64',
1513
+ 'win32:x64': 'processos-win32-x64.exe',
1514
+ };
1515
+ return map[`${platform}:${arch}`] || null;
1516
+ }
1517
+
1518
+ /**
1519
+ * Join a PROCESSOS_DOWNLOAD_URL base with a leaf (`processos-<arch>` or
1520
+ * `version.json`). The base is normally a directory/prefix (e.g. the S3
1521
+ * `.../processos/latest/` URL); if it already points straight at a binary
1522
+ * asset, we treat its parent directory as the base so siblings resolve too.
1523
+ */
1524
+ function processosDownloadBase(rawUrl) {
1525
+ const t = String(rawUrl || '').trim();
1526
+ if (!t) return '';
1527
+ if (t.endsWith('/')) return t.slice(0, -1);
1528
+ const lastSeg = t.split('/').pop();
1529
+ // A direct link to a binary asset -> use its parent as the base.
1530
+ if (lastSeg.startsWith('processos-') || lastSeg === 'processos' || lastSeg.endsWith('.exe')) {
1531
+ return t.slice(0, t.length - lastSeg.length - 1);
1532
+ }
1533
+ return t;
1534
+ }
1535
+
1536
+ function processosBinaryUrl(rawUrl) {
1537
+ const asset = processosAssetName();
1538
+ if (!asset) {
1539
+ throw new Error(
1540
+ `No prebuilt ProcessOS binary is published for this platform (${process.platform}/${process.arch}).`,
1541
+ );
1542
+ }
1543
+ return `${processosDownloadBase(rawUrl)}/${asset}`;
1544
+ }
1545
+
1546
+ function processosVersionMetaUrl(rawUrl) {
1547
+ return `${processosDownloadBase(rawUrl)}/${PROCESSOS_VERSION_META}`;
1548
+ }
1549
+
1550
+ /** Fetch and parse the remote version.json (best-effort; null on any failure). */
1551
+ async function fetchProcessosVersionMeta(rawUrl, timeoutMs = 4000) {
1552
+ try {
1553
+ const ctrl = new AbortController();
1554
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
1555
+ const res = await fetch(processosVersionMetaUrl(rawUrl), { redirect: 'follow', signal: ctrl.signal });
1556
+ clearTimeout(t);
1557
+ if (!res.ok) return null;
1558
+ const j = await res.json();
1559
+ return j && typeof j === 'object' ? j : null;
1560
+ } catch {
1561
+ return null;
1562
+ }
1563
+ }
1564
+
1565
+ /** Download a binary to `dest` (atomic via temp + rename; +x on unix). */
1566
+ async function downloadProcessosBinary(url, dest) {
1567
+ const logger = getLogger();
1568
+ logger.info(`Downloading ProcessOS for ${process.platform}/${process.arch} from ${url} ...`);
1569
+ const res = await fetch(url, { redirect: 'follow' });
1570
+ if (!res.ok) {
1571
+ throw new Error(`ProcessOS download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
1572
+ }
1573
+ const buf = Buffer.from(await res.arrayBuffer());
1574
+ mkdirSync(getProcessosBinDir(), { recursive: true });
1575
+ const tmp = `${dest}.download`;
1576
+ writeFileSync(tmp, buf);
1577
+ if (process.platform !== 'win32') chmodSync(tmp, 0o755);
1578
+ renameSync(tmp, dest);
1579
+ logger.info(`Saved ProcessOS to ${dest} (${(buf.length / 1_000_000).toFixed(1)} MB).`);
1580
+ return dest;
1581
+ }
1582
+
1583
+ /**
1584
+ * Resolve the ProcessOS binary to run, downloading it on demand when the user
1585
+ * has a PROCESSOS_DOWNLOAD_URL but no local copy yet. Resolution:
1586
+ * configured/local binary -> cached download -> fresh download -> error.
1587
+ */
1588
+ async function resolveProcessosBinary(req) {
1589
+ const configured = findConfiguredProcessosBinary(req); // may throw on a missing configured path
1590
+ if (configured) return configured;
1591
+
1592
+ const dlUrl = process.env.PROCESSOS_DOWNLOAD_URL;
1593
+ if (dlUrl) {
1594
+ const dest = getProcessosCachedBinaryPath();
1595
+ const meta = await fetchProcessosVersionMeta(dlUrl);
1596
+ await downloadProcessosBinary(processosBinaryUrl(dlUrl), dest);
1597
+ // Record what we fetched so the update notifier can compare later.
1598
+ try {
1599
+ mkdirSync(getProcessosBinDir(), { recursive: true });
1600
+ writeFileSync(
1601
+ getProcessosBinaryMetaPath(),
1602
+ JSON.stringify({
1603
+ version: meta?.version ?? null,
1604
+ commit: meta?.commit ?? null,
1605
+ updated: meta?.updated ?? null,
1606
+ source: processosDownloadBase(dlUrl),
1607
+ downloaded: new Date().toISOString(),
1608
+ }),
1609
+ );
1610
+ } catch {
1611
+ /* sidecar is best-effort */
1612
+ }
1613
+ return dest;
1332
1614
  }
1615
+
1333
1616
  throw new Error(
1334
- `Could not find the ProcessOS binary.\n` +
1335
- `ProcessOS is not distributed via npm — download the binary for your platform, then point the plugin at it:\n` +
1336
- ` c8ctl processos set bin <path> (or --binary <path>, or export PROCESSOS_BINARY)\n` +
1337
- `Alternatively build from source: (cd ${repo} && make processos-build-release)\n` +
1338
- `Looked for a local build in:\n ${candidates.join('\n ')}`,
1617
+ `Could not find or download the ProcessOS binary.\n` +
1618
+ `Set the download URL you were given (PROCESSOS_DOWNLOAD_URL), point the plugin at a\n` +
1619
+ `local binary ("c8ctl processos set bin <path>" / --binary / PROCESSOS_BINARY), or build\n` +
1620
+ `from source under the nanobpmn repo.`,
1621
+ );
1622
+ }
1623
+
1624
+ /**
1625
+ * Whether ProcessOS is enabled for this user. It is a closed alpha, so the
1626
+ * operational commands stay locked until the user either has the binary on
1627
+ * their system (configured path / cached download / local build) or has been
1628
+ * given a PROCESSOS_DOWNLOAD_URL to fetch it from.
1629
+ */
1630
+ function processosEnabled(req) {
1631
+ if (process.env.PROCESSOS_DOWNLOAD_URL) return true;
1632
+ try {
1633
+ if (findConfiguredProcessosBinary(req)) return true;
1634
+ } catch {
1635
+ // A configured-but-missing path still means the user opted in; let the real
1636
+ // not-found error surface from the command rather than the closed-alpha gate.
1637
+ return true;
1638
+ }
1639
+ return false;
1640
+ }
1641
+
1642
+ function printProcessosClosedAlpha() {
1643
+ const logger = getLogger();
1644
+ logger.error(
1645
+ 'ProcessOS is in closed alpha and is not available yet.\n' +
1646
+ '\n' +
1647
+ 'To enable it, set the download URL you were given by the Nano BPM team:\n' +
1648
+ ' export PROCESSOS_DOWNLOAD_URL=<url>\n' +
1649
+ ' c8ctl processos start # downloads + runs the matching binary\n' +
1650
+ '\n' +
1651
+ 'or, if you already have the binary, point the plugin at it:\n' +
1652
+ ' c8ctl processos set bin <path>',
1339
1653
  );
1340
1654
  }
1341
1655
 
1656
+ // --- ProcessOS update notifier ---------------------------------------------
1657
+ // Mirrors the nano plugin notifier, but the "latest version" comes from the
1658
+ // version.json the nanobpmn CI publishes next to the S3 binaries rather than
1659
+ // from npm. Throttled to one background fetch + one notice per day.
1660
+
1661
+ function getProcessosUpdateCacheFile() {
1662
+ return join(getStateHome(), PROCESSOS_UPDATE_CACHE_FILE);
1663
+ }
1664
+
1665
+ function readProcessosUpdateCache() {
1666
+ try {
1667
+ return JSON.parse(readFileSync(getProcessosUpdateCacheFile(), 'utf8'));
1668
+ } catch {
1669
+ return {};
1670
+ }
1671
+ }
1672
+
1673
+ function writeProcessosUpdateCache(obj) {
1674
+ try {
1675
+ mkdirSync(getStateHome(), { recursive: true });
1676
+ writeFileSync(getProcessosUpdateCacheFile(), JSON.stringify(obj));
1677
+ } catch {
1678
+ /* best-effort */
1679
+ }
1680
+ }
1681
+
1682
+ /**
1683
+ * The installed ProcessOS version: the recorded version of an auto-downloaded
1684
+ * binary, else `processos --version` against the resolved binary. Null when no
1685
+ * binary is present or it can't report a version.
1686
+ */
1687
+ function getInstalledProcessosVersion(req) {
1688
+ const meta = readProcessosBinaryMeta();
1689
+ if (meta.version) return String(meta.version);
1690
+ let binary = null;
1691
+ try {
1692
+ binary = findConfiguredProcessosBinary(req);
1693
+ } catch {
1694
+ binary = null;
1695
+ }
1696
+ if (!binary) return null;
1697
+ try {
1698
+ const res = spawnSync(binary, ['--version'], { encoding: 'utf8', timeout: 3000 });
1699
+ if (res.status === 0) {
1700
+ const m = String(res.stdout || '').match(/(\d+\.\d+\.\d+[^\s]*)/);
1701
+ if (m) return m[1];
1702
+ }
1703
+ } catch {
1704
+ /* ignore */
1705
+ }
1706
+ return null;
1707
+ }
1708
+
1709
+ /**
1710
+ * Refresh the cached latest ProcessOS version in a detached background process
1711
+ * (fetches version.json), so the current command never waits on the network.
1712
+ */
1713
+ function spawnProcessosVersionRefresh(metaUrl, cacheFile) {
1714
+ const script =
1715
+ 'const{readFileSync,writeFileSync}=require("fs");' +
1716
+ `let prev={};try{prev=JSON.parse(readFileSync(${JSON.stringify(cacheFile)},"utf8"))}catch{}` +
1717
+ 'const out=Object.assign({},prev,{lastCheck:Date.now()});' +
1718
+ 'const ac=new AbortController();const t=setTimeout(()=>ac.abort(),5000);' +
1719
+ `fetch(${JSON.stringify(metaUrl)},{redirect:"follow",signal:ac.signal})` +
1720
+ '.then(r=>r.ok?r.json():null).then(j=>{clearTimeout(t);' +
1721
+ 'if(j&&j.version){out.latest=String(j.version);out.commit=j.commit||null}' +
1722
+ `try{writeFileSync(${JSON.stringify(cacheFile)},JSON.stringify(out))}catch{}})` +
1723
+ `.catch(()=>{try{writeFileSync(${JSON.stringify(cacheFile)},JSON.stringify(out))}catch{}});`;
1724
+ try {
1725
+ const child = spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' });
1726
+ child.unref();
1727
+ } catch {
1728
+ /* skip this cycle */
1729
+ }
1730
+ }
1731
+
1732
+ function printProcessosUpdateNotice(current, latest) {
1733
+ const lines = [
1734
+ '',
1735
+ `╭─ ProcessOS update available: v${current ?? '?'} → v${latest}`,
1736
+ '│ A newer ProcessOS build is published.',
1737
+ '│ Get it: c8ctl processos stop && c8ctl processos start',
1738
+ '│ (a configured binary updates itself; a downloaded one re-fetches)',
1739
+ '╰─ Pin a specific build instead with: c8ctl processos set bin <path>',
1740
+ '',
1741
+ ];
1742
+ for (const l of lines) console.error(l);
1743
+ }
1744
+
1745
+ /**
1746
+ * Best-effort, non-blocking ProcessOS update check. Triggers a background
1747
+ * version.json fetch when the cache is stale and prints a notice (at most once
1748
+ * per day) when the published version is newer than the installed one. Only
1749
+ * meaningful when a download URL is configured (the closed-alpha channel).
1750
+ */
1751
+ function maybeNotifyProcessosUpdate(req) {
1752
+ try {
1753
+ if (updateNotifierDisabled()) return;
1754
+ const dlUrl = process.env.PROCESSOS_DOWNLOAD_URL;
1755
+ if (!dlUrl) return; // no published channel to compare against
1756
+ const current = getInstalledProcessosVersion(req);
1757
+ if (!current) return;
1758
+
1759
+ const cacheFile = getProcessosUpdateCacheFile();
1760
+ const cache = readProcessosUpdateCache();
1761
+ const now = Date.now();
1762
+
1763
+ if (!cache.lastCheck || now - cache.lastCheck > UPDATE_CHECK_TTL_MS) {
1764
+ try {
1765
+ mkdirSync(getStateHome(), { recursive: true });
1766
+ } catch {
1767
+ /* ignore */
1768
+ }
1769
+ spawnProcessosVersionRefresh(processosVersionMetaUrl(dlUrl), cacheFile);
1770
+ }
1771
+
1772
+ const latest = cache.latest;
1773
+ if (!latest || compareSemver(current, latest) >= 0) return;
1774
+ if (cache.lastNotified && now - cache.lastNotified <= UPDATE_NOTIFY_TTL_MS) return;
1775
+
1776
+ printProcessosUpdateNotice(current, latest);
1777
+ writeProcessosUpdateCache({ ...cache, lastNotified: now });
1778
+ } catch {
1779
+ /* never break a command over the notifier */
1780
+ }
1781
+ }
1782
+
1342
1783
  /** Probe ProcessOS's GET /health endpoint for reachability. */
1343
1784
  async function probeProcessosHealthy(url) {
1344
1785
  return probePath(url, '/health');
@@ -1368,7 +1809,7 @@ async function startProcessos(req) {
1368
1809
  await stopProcessos({});
1369
1810
  }
1370
1811
 
1371
- const binary = findProcessosBinary(req);
1812
+ const binary = await resolveProcessosBinary(req);
1372
1813
  const port = getProcessosPort(req);
1373
1814
  const url = `http://127.0.0.1:${port}`;
1374
1815
  const nanoUrl = req.nanoUrl || getProcessosNanoUrl();
@@ -1702,6 +2143,15 @@ function showProcessosConfig() {
1702
2143
  }
1703
2144
  }
1704
2145
  console.log('');
2146
+ console.log(' closed-alpha channel:');
2147
+ console.log(` download url ${process.env.PROCESSOS_DOWNLOAD_URL || '(not set — ProcessOS is a closed alpha; set PROCESSOS_DOWNLOAD_URL to enable)'}`);
2148
+ const cached = getProcessosCachedBinaryPath();
2149
+ const meta = readProcessosBinaryMeta();
2150
+ console.log(` cached binary ${existsSync(cached) ? cached : '(none — downloaded on first "processos start")'}`);
2151
+ if (meta.version || meta.commit) {
2152
+ console.log(` version ${meta.version || '?'}${meta.commit ? ` (${String(meta.commit).slice(0, 8)})` : ''}${meta.downloaded ? ` downloaded ${meta.downloaded}` : ''}`);
2153
+ }
2154
+ console.log('');
1705
2155
  console.log(` state file ${getProcessosStateFile()}`);
1706
2156
  console.log(` log file ${getProcessosLogFile()}`);
1707
2157
  console.log('');
@@ -1720,7 +2170,9 @@ function printProcessosUsage() {
1720
2170
  console.log(' c8ctl processos set bin <path> | port <n> | nano-url <url> | data-dir <path> | env KEY=VALUE');
1721
2171
  console.log(' c8ctl processos config');
1722
2172
  console.log('');
1723
- console.log('ProcessOS is downloaded manually; point the plugin at it with "c8ctl processos set bin <path>".');
2173
+ console.log('ProcessOS is a closed alpha. Enable it with the download URL you were given:');
2174
+ console.log(' export PROCESSOS_DOWNLOAD_URL=<url> # plugin downloads + runs the matching binary');
2175
+ console.log('or point the plugin at a binary you already have: "c8ctl processos set bin <path>".');
1724
2176
  console.log('By default ProcessOS spawns its own internal pilot Nano engine (the plugin auto-wires the nano');
1725
2177
  console.log('binary into PROCESSOS_NANO_BIN). Use --no-spawn-nano to instead use the --nano-url engine for');
1726
2178
  console.log('the pilot too. If no nano binary is available, it falls back to --no-spawn-nano automatically.');
@@ -1784,7 +2236,8 @@ export const metadata = {
1784
2236
  processos: {
1785
2237
  description: 'Manage a local ProcessOS instance — start, status, stop, logs, config',
1786
2238
  examples: [
1787
- { command: 'c8ctl processos set bin <path>', description: 'Point the plugin at the downloaded ProcessOS binary' },
2239
+ { command: 'export PROCESSOS_DOWNLOAD_URL=<url>', description: 'Enable the closed alpha + auto-download the matching binary' },
2240
+ { command: 'c8ctl processos set bin <path>', description: 'Point the plugin at a ProcessOS binary you already have' },
1788
2241
  { command: 'c8ctl processos start', description: 'Start ProcessOS against the local Nano BPM engine' },
1789
2242
  { command: 'c8ctl processos start --nano-url http://localhost:8080', description: 'Start against a specific engine' },
1790
2243
  { command: 'c8ctl processos status', description: 'Show ProcessOS status and health' },
@@ -1824,6 +2277,7 @@ export const commands = {
1824
2277
  return;
1825
2278
  }
1826
2279
 
2280
+ let failed = false;
1827
2281
  try {
1828
2282
  switch (req.subcommand) {
1829
2283
  case 'start':
@@ -1864,8 +2318,10 @@ export const commands = {
1864
2318
  }
1865
2319
  } catch (error) {
1866
2320
  logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
1867
- process.exit(1);
2321
+ failed = true;
1868
2322
  }
2323
+ maybeNotifyUpdate(req.subcommand);
2324
+ if (failed) process.exit(1);
1869
2325
  },
1870
2326
  },
1871
2327
  processos: {
@@ -1887,6 +2343,16 @@ export const commands = {
1887
2343
  return;
1888
2344
  }
1889
2345
 
2346
+ // ProcessOS is a closed alpha: gate the operational commands until the
2347
+ // user has opted in (download URL set or a binary on their system).
2348
+ // `set`/`config` stay open so users can configure/inspect at any time.
2349
+ const ungated = req.subcommand === 'set' || req.subcommand === 'config';
2350
+ if (!ungated && !processosEnabled(req)) {
2351
+ printProcessosClosedAlpha();
2352
+ process.exit(1);
2353
+ }
2354
+
2355
+ let failed = false;
1890
2356
  try {
1891
2357
  switch (req.subcommand) {
1892
2358
  case 'start':
@@ -1915,8 +2381,11 @@ export const commands = {
1915
2381
  }
1916
2382
  } catch (error) {
1917
2383
  logger.error(`processos ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
1918
- process.exit(1);
2384
+ failed = true;
1919
2385
  }
2386
+ maybeNotifyUpdate(req.subcommand);
2387
+ maybeNotifyProcessosUpdate(req);
2388
+ if (failed) process.exit(1);
1920
2389
  },
1921
2390
  },
1922
2391
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
@@ -49,10 +49,10 @@
49
49
  "semantic-release": "^25.0.3"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.1.0",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.1.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.1.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.1.0",
56
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.1.0"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.2.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.2.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.2.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.2.0",
56
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.2.0"
57
57
  }
58
58
  }