@camunda8/cli 2.7.0-alpha.2 → 2.7.0-alpha.4

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.
@@ -8,7 +8,7 @@ A default [c8ctl](https://github.com/camunda/c8ctl) plugin that provides an opin
8
8
  # Start with a specific version
9
9
  c8ctl cluster start 8.9.0-alpha5
10
10
 
11
- # Start using a version alias (dynamically resolved)
11
+ # Start using a version alias
12
12
  c8ctl cluster start stable
13
13
  c8ctl cluster start alpha
14
14
 
@@ -81,14 +81,18 @@ export async function discoverLatestVersions() {
81
81
  * - Minor version directories (e.g. "8.8/", "8.9/") are rolling releases
82
82
  * updated in-place.
83
83
  * - An alpha train exists when there are "X.Y.0-alphaN/" directories
84
- * for a given minor. The highest minor with alphas is the alpha alias.
85
- * - The highest minor without alphas is the stable alias.
84
+ * for a given minor that has NOT yet shipped a GA release (X.Y.0/).
85
+ * The highest such minor is the alpha train.
86
+ * - stable = highest minor that is NOT in the alpha train.
87
+ * - alpha = highest minor overall (regardless of alpha train membership).
86
88
  */
87
89
  export function parseVersionsFromHtml(html) {
88
90
  // Match minor-version directories like "8.8/", "8.9/"
89
91
  const minorMatches = [...html.matchAll(/href="(\d+\.\d+)\/"/g)].map(m => m[1]);
90
92
  // Match alpha directories like "8.9.0-alpha5/"
91
93
  const alphaMatches = [...html.matchAll(/href="(\d+\.\d+)\.0-alpha\d+\/"/g)].map(m => m[1]);
94
+ // Match GA release directories like "8.9.0/"
95
+ const gaMatches = [...html.matchAll(/href="(\d+\.\d+)\.0\/"/g)].map(m => m[1]);
92
96
 
93
97
  if (minorMatches.length === 0) return null;
94
98
 
@@ -99,22 +103,17 @@ export function parseVersionsFromHtml(html) {
99
103
  };
100
104
 
101
105
  const sortedMinors = [...new Set(minorMatches)].sort(compareSemver);
102
- const alphaSet = new Set(alphaMatches);
106
+ const gaSet = new Set(gaMatches);
107
+
108
+ // A minor is only in the alpha train if it has alpha dirs but NO GA release yet.
109
+ // Once X.Y.0/ exists, that minor has graduated.
110
+ const alphaOnlyMinors = [...new Set(alphaMatches)].filter(v => !gaSet.has(v));
111
+ const alphaSet = new Set(alphaOnlyMinors);
103
112
 
104
113
  const highestMinor = sortedMinors[sortedMinors.length - 1];
105
114
 
106
- // The alpha train is the highest minor that has alpha directories.
107
- // The stable release is the minor just below the alpha train,
108
- // or the highest minor if no alpha train exists.
109
- const highestAlphaMinor = [...alphaSet].sort(compareSemver).pop();
110
-
111
- let stable;
112
- if (highestAlphaMinor) {
113
- // Stable = the highest minor that is lower than the alpha train
114
- stable = sortedMinors.filter(v => compareSemver(v, highestAlphaMinor) < 0).pop() || highestMinor;
115
- } else {
116
- stable = highestMinor;
117
- }
115
+ // Stable = highest minor that is NOT in the alpha train
116
+ const stable = sortedMinors.filter(v => !alphaSet.has(v)).pop() || highestMinor;
118
117
 
119
118
  return {
120
119
  stable,
@@ -132,24 +131,34 @@ async function getDynamicAliases() {
132
131
  return _dynamicAliases;
133
132
  }
134
133
 
135
- async function resolveVersion(versionSpec, { preferLocal = false, cacheDir } = {}) {
134
+ export async function resolveVersion(versionSpec, { preferLocal = false, cacheDir } = {}) {
136
135
  if (!isVersionAlias(versionSpec)) return versionSpec;
137
136
 
138
- // When preferLocal is set (e.g. start), try the persisted alias mapping first
137
+ // start: use cached alias if the version is installed (deterministic)
139
138
  if (preferLocal && cacheDir) {
140
139
  const local = readLocalAliasMapping(cacheDir, versionSpec);
141
140
  if (local) return local;
142
141
  }
143
142
 
143
+ // Try remote discovery
144
144
  const dynamic = await getDynamicAliases();
145
- const resolved = dynamic?.[versionSpec] ?? _fallbackAliases[versionSpec] ?? versionSpec;
146
145
 
147
- // Persist the resolved mapping for future offline use
148
- if (cacheDir && dynamic?.[versionSpec]) {
149
- storeLocalAliasMapping(cacheDir, versionSpec, resolved);
146
+ if (dynamic?.[versionSpec]) {
147
+ // Persist for offline use and future preferLocal lookups
148
+ if (cacheDir) {
149
+ storeLocalAliasMapping(cacheDir, versionSpec, dynamic[versionSpec]);
150
+ }
151
+ return dynamic[versionSpec];
152
+ }
153
+
154
+ // Remote unavailable — fall back to persisted alias if the version is installed
155
+ if (!preferLocal && cacheDir) {
156
+ const local = readLocalAliasMapping(cacheDir, versionSpec);
157
+ if (local) return local;
150
158
  }
151
159
 
152
- return resolved;
160
+ // Last resort: hardcoded fallback from package.json
161
+ return _fallbackAliases[versionSpec] ?? versionSpec;
153
162
  }
154
163
 
155
164
  function getAliasMappingPath(cacheDir, alias) {
@@ -169,7 +178,7 @@ function readLocalAliasMapping(cacheDir, alias) {
169
178
  }
170
179
  }
171
180
 
172
- function storeLocalAliasMapping(cacheDir, alias, resolved) {
181
+ export function storeLocalAliasMapping(cacheDir, alias, resolved) {
173
182
  try {
174
183
  mkdirSync(cacheDir, { recursive: true });
175
184
  writeFileSync(getAliasMappingPath(cacheDir, alias), resolved);
@@ -184,11 +193,66 @@ async function getVersionAliasEntries() {
184
193
  return Object.entries(aliases);
185
194
  }
186
195
 
196
+ /**
197
+ * Synchronous variant that uses only the cached discovery result or fallback.
198
+ * Used for help output so it never blocks on a network request.
199
+ */
200
+ function getVersionAliasEntriesSync() {
201
+ const aliases = _dynamicAliases || _fallbackAliases;
202
+ return Object.entries(aliases);
203
+ }
204
+
187
205
  /** Reset the cached dynamic aliases (for testing). */
188
206
  export function _resetDynamicAliasCache() {
189
207
  _dynamicAliases = undefined;
190
208
  }
191
209
 
210
+ /**
211
+ * Non-blocking background check: query the remote download server and print
212
+ * a hint if the remote alias resolves to a different (newer) version.
213
+ *
214
+ * Important: does NOT update the persisted alias mapping. The mapping is only
215
+ * updated when the user explicitly runs `cluster install`, keeping `start`
216
+ * deterministic across runs.
217
+ *
218
+ * Returns the fire-and-forget promise (for testing).
219
+ */
220
+ export function checkBackgroundAliasFreshness(versionSpec, resolvedVersion) {
221
+ const logger = getLogger();
222
+ const controller = new AbortController();
223
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
224
+ return fireAndForget(
225
+ fetch(DOWNLOAD_BASE_URL, { signal: controller.signal })
226
+ .then((res) => res.ok ? res.text() : null)
227
+ .then((html) => {
228
+ if (html) {
229
+ const remote = parseVersionsFromHtml(html);
230
+ const remoteVersion = remote?.[versionSpec];
231
+ if (remoteVersion && remoteVersion !== resolvedVersion) {
232
+ logger.info(
233
+ `A newer "${versionSpec}" release is available (${remoteVersion}). ` +
234
+ `Install it with: c8ctl cluster install ${versionSpec}`,
235
+ );
236
+ }
237
+ }
238
+ })
239
+ .finally(() => clearTimeout(timeoutId)),
240
+ );
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Helpers – fire-and-forget
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /**
248
+ * Run a promise without blocking the caller. Errors are always swallowed so
249
+ * fire-and-forget chains never produce unhandled-rejection warnings.
250
+ * Returns the guarded promise (useful in tests).
251
+ */
252
+ function fireAndForget(promise) {
253
+ return promise.catch(() => {});
254
+ }
255
+
192
256
  // ---------------------------------------------------------------------------
193
257
  // Plugin metadata
194
258
  // ---------------------------------------------------------------------------
@@ -626,17 +690,16 @@ export async function ensureC8RunInstalled(config) {
626
690
  // For rolling versions on start, do a bounded non-blocking check and hint.
627
691
  // We store the promise so tests can await it, but we don't block the caller.
628
692
  if (config.checkForUpdateHint) {
629
- config._hintPromise = hasNewerVersionAvailable(config)
630
- .then((hasUpdate) => {
631
- if (hasUpdate) {
632
- logger.info(
633
- `A newer server version is available. Install it with: c8ctl cluster install ${config.version}`,
634
- );
635
- }
636
- })
637
- .catch(() => {
638
- // Swallow — offline or timeout, don't bother the user
639
- });
693
+ config._hintPromise = fireAndForget(
694
+ hasNewerVersionAvailable(config)
695
+ .then((hasUpdate) => {
696
+ if (hasUpdate) {
697
+ logger.info(
698
+ `A newer server version is available. Install it with: c8ctl cluster install ${config.version}`,
699
+ );
700
+ }
701
+ }),
702
+ );
640
703
  }
641
704
 
642
705
  return;
@@ -733,6 +796,7 @@ function printSummary(rawOutput, version) {
733
796
  async function startC8Run(config, debug = false) {
734
797
  const logger = getLogger();
735
798
  const binaryPath = getC8RunBinaryPath(config);
799
+ const logDir = join(dirname(binaryPath), 'log');
736
800
 
737
801
  if (!existsSync(binaryPath)) {
738
802
  throw new Error(`c8run binary not found at ${binaryPath}`);
@@ -761,7 +825,9 @@ async function startC8Run(config, debug = false) {
761
825
  });
762
826
 
763
827
  if (typeof proc.pid !== 'number') {
764
- logger.error('Failed to start cluster process: no PID received from c8run. Check logs for details.');
828
+ logger.error('Failed to start cluster process: no PID received from c8run.');
829
+ logger.error(`Check logs in: ${logDir}`);
830
+ logger.info(`Print logs with: cat "${logDir}"/*.log`);
765
831
  process.exit(1);
766
832
  }
767
833
 
@@ -818,8 +884,10 @@ async function startC8Run(config, debug = false) {
818
884
  printSummary(startupOutput, config.version);
819
885
  } else {
820
886
  logger.error(
821
- 'Cluster failed to start within timeout. Check logs for details.',
887
+ 'Cluster failed to start within timeout.',
822
888
  );
889
+ logger.error(`Check logs in: ${logDir}`);
890
+ logger.info(`Print logs with: cat "${logDir}"/*.log`);
823
891
  process.exit(1);
824
892
  }
825
893
  }
@@ -1044,7 +1112,7 @@ export async function listInstalledVersions(cacheDir) {
1044
1112
  }
1045
1113
 
1046
1114
  console.log('');
1047
- console.log('Version aliases (dynamically resolved):');
1115
+ console.log('Version aliases:');
1048
1116
  for (const [alias, resolved] of aliasEntries) {
1049
1117
  console.log(` ${alias.padEnd(ALIAS_COLUMN_WIDTH)} → ${resolved}`);
1050
1118
  }
@@ -1103,7 +1171,7 @@ export async function listRemoteVersions() {
1103
1171
  }
1104
1172
 
1105
1173
  console.log('');
1106
- console.log('Version aliases (dynamically resolved):');
1174
+ console.log('Version aliases:');
1107
1175
  for (const [alias, resolved] of aliasEntries) {
1108
1176
  console.log(` ${alias.padEnd(ALIAS_COLUMN_WIDTH)} → ${resolved}`);
1109
1177
  }
@@ -1125,9 +1193,11 @@ export async function deleteVersion(cacheDir, versionSpec) {
1125
1193
  validateVersionSpec(versionSpec);
1126
1194
 
1127
1195
  // Resolve named aliases (stable/alpha) to the actual cached version name.
1196
+ // Use preferLocal so we delete the version that's actually installed,
1197
+ // not whatever the remote currently resolves the alias to.
1128
1198
  // Major.minor patterns like 8.8 are used as-is since the cache dir is named c8run-8.8.
1129
1199
  const resolvedVersion = isVersionAlias(versionSpec)
1130
- ? await resolveVersion(versionSpec)
1200
+ ? await resolveVersion(versionSpec, { preferLocal: true, cacheDir })
1131
1201
  : versionSpec;
1132
1202
 
1133
1203
  // Prevent deleting a currently running version
@@ -1319,15 +1389,15 @@ export const commands = {
1319
1389
  console.log(' --debug Stream raw c8run output during start');
1320
1390
  console.log('');
1321
1391
  console.log('A <version> can be:');
1322
- console.log(' stable / alpha Named aliases (dynamically resolved to latest)');
1392
+ console.log(' stable / alpha Named aliases');
1323
1393
  console.log(' 8.8, 8.9 Major.minor — rolling release for that minor');
1324
1394
  console.log(' 8.9.0-alpha5 Exact pinned version');
1325
1395
  console.log('');
1326
- console.log(' start uses a local version if available (no remote check).');
1396
+ console.log(' start uses a locally installed version if available.');
1327
1397
  console.log(' install always checks the remote for a newer rolling release.');
1328
1398
  console.log('');
1329
- console.log('Version aliases (dynamically resolved):')
1330
- for (const [alias, resolved] of await getVersionAliasEntries()) {
1399
+ console.log('Version aliases:');
1400
+ for (const [alias, resolved] of getVersionAliasEntriesSync()) {
1331
1401
  console.log(` ${alias.padEnd(ALIAS_COLUMN_WIDTH)} → ${resolved}`);
1332
1402
  }
1333
1403
  console.log('');
@@ -1416,14 +1486,21 @@ export const commands = {
1416
1486
  process.exit(1);
1417
1487
  }
1418
1488
  const theCacheDir = getCacheDir();
1489
+ const isStart = parsed.subcommand === 'start';
1419
1490
  const version = await resolveVersion(versionSpec, {
1420
- // start: prefer locally-cached alias mapping to avoid a network fetch when already installed
1421
- preferLocal: parsed.subcommand === 'start',
1491
+ // start: use cached alias if installed (deterministic behavior)
1492
+ preferLocal: isStart,
1422
1493
  cacheDir: theCacheDir,
1423
1494
  });
1424
1495
  if (isVersionAlias(versionSpec)) {
1425
1496
  logger.info(`Resolved alias "${versionSpec}" → ${version}`);
1426
1497
  }
1498
+
1499
+ // For start with a named alias, check in the background whether the
1500
+ // remote resolves to a different (newer) version and hint if so.
1501
+ if (isStart && isVersionAlias(versionSpec)) {
1502
+ checkBackgroundAliasFreshness(versionSpec, version);
1503
+ }
1427
1504
  const rolling = isRollingVersion(versionSpec);
1428
1505
  const config = {
1429
1506
  cacheDir: theCacheDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camunda8/cli",
3
- "version": "2.7.0-alpha.2",
3
+ "version": "2.7.0-alpha.4",
4
4
  "description": "Camunda 8 CLI - minimal-dependency CLI for Camunda 8 operations",
5
5
  "type": "module",
6
6
  "engines": {