@devicecloud.dev/dcd 5.0.0-beta.1 → 5.0.0-beta.2

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moropo Ltd t/a DeviceCloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -92,3 +92,20 @@ A [gitleaks](https://github.com/gitleaks/gitleaks) scan runs in two places, both
92
92
  - **CI** — the `secret-scan` job scans the full history on every push and pull request, and is the enforced backstop regardless of local setup.
93
93
 
94
94
 
95
+ ## Contributing
96
+
97
+ Contributions are welcome! Read **[CONTRIBUTING.md](CONTRIBUTING.md)** for local
98
+ setup, our commit/PR conventions (Conventional Commit PR titles, squash-merge),
99
+ and how releases work. All contributors sign our
100
+ [Contributor License Agreement](CLA.md) — the bot prompts you on your first PR —
101
+ and follow our [Code of Conduct](CODE_OF_CONDUCT.md).
102
+
103
+ Found a security issue? Please **don't** open a public issue — see
104
+ [SECURITY.md](SECURITY.md).
105
+
106
+
107
+ ## License
108
+
109
+ [MIT](LICENSE) © Moropo Ltd t/a DeviceCloud
110
+
111
+
@@ -1,5 +1,6 @@
1
1
  /* eslint-disable complexity */
2
2
  import { defineCommand } from 'citty';
3
+ import { existsSync } from 'node:fs';
3
4
  import * as path from 'node:path';
4
5
  import { flags as allFlags } from '../constants.js';
5
6
  import { ApiError, ApiGateway } from '../gateways/api-gateway.js';
@@ -16,7 +17,7 @@ import { VersionService } from '../services/version.service.js';
16
17
  import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, } from '../types/domain/device.types.js';
17
18
  import { resolveAuth } from '../utils/auth.js';
18
19
  import { isCI } from '../utils/ci.js';
19
- import { CliError, coerceArray, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
20
+ import { CliError, coerceArray, collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
20
21
  import { fetchCompatibilityData, } from '../utils/compatibility.js';
21
22
  import { resolveApiUrl } from '../utils/config-store.js';
22
23
  import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
@@ -69,7 +70,7 @@ export const cloudCommand = defineCommand({
69
70
  },
70
71
  },
71
72
  // eslint-disable-next-line complexity
72
- async run({ args }) {
73
+ async run({ args, rawArgs }) {
73
74
  const cliVersion = getCliVersion();
74
75
  const deviceValidationService = new DeviceValidationService();
75
76
  const moropoService = new MoropoService();
@@ -78,11 +79,13 @@ export const cloudCommand = defineCommand({
78
79
  const testSubmissionService = new TestSubmissionService();
79
80
  const versionService = new VersionService();
80
81
  const versionCheck = async () => {
81
- const latestVersion = await versionService.checkLatestCliVersion();
82
- if (latestVersion && versionService.isOutdated(cliVersion, latestVersion)) {
82
+ const result = await versionService.checkLatestCliVersion(cliVersion);
83
+ if (result.ok &&
84
+ result.version &&
85
+ versionService.isOutdated(cliVersion, result.version)) {
83
86
  out(ui.warn(colors.bold('Update available')));
84
87
  out(ui.branch([
85
- `A new version of the DeviceCloud CLI is available: ${colors.highlight(latestVersion)}`,
88
+ `A new version of the DeviceCloud CLI is available: ${colors.highlight(result.version)}`,
86
89
  `${colors.dim('Run:')} ${colors.info(getUpgradeCommand())}`,
87
90
  ]));
88
91
  }
@@ -119,13 +122,15 @@ export const cloudCommand = defineCommand({
119
122
  const deviceLocale = args['device-locale'];
120
123
  const downloadArtifacts = validateEnum(args['download-artifacts'], DOWNLOAD_OPTIONS, 'download-artifacts');
121
124
  const dryRun = Boolean(args['dry-run']);
122
- const env = coerceArray(args.env, false);
123
- const excludeFlows = coerceArray(args['exclude-flows']);
124
- const excludeTags = coerceArray(args['exclude-tags']);
125
+ // Repeatable flags are collected from rawArgs: citty/parseArgs only keeps
126
+ // the last occurrence, so reading args.* directly drops earlier values.
127
+ const env = coerceArray(collectRepeatedFlag(rawArgs, ['--env', '-e']), false);
128
+ const excludeFlows = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-flows']));
129
+ const excludeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--exclude-tags']));
125
130
  let flows = args.flows;
126
131
  const googlePlay = Boolean(args['google-play']);
127
132
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
128
- const includeTags = coerceArray(args['include-tags']);
133
+ const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
129
134
  const iOSDevice = validateEnum(args['ios-device'], Object.values(EiOSDevices), 'ios-device');
130
135
  const iOSVersion = validateEnum(args['ios-version'], Object.values(EiOSVersions), 'ios-version');
131
136
  const androidApiLevel = validateEnum(args['android-api-level'], Object.values(EAndroidApiLevels), 'android-api-level');
@@ -135,7 +140,7 @@ export const cloudCommand = defineCommand({
135
140
  const jsonFileFlag = Boolean(args['json-file']);
136
141
  const jsonFileName = args['json-file-name'];
137
142
  const maestroVersion = args['maestro-version'];
138
- const metadata = coerceArray(args.metadata, false);
143
+ const metadata = coerceArray(collectRepeatedFlag(rawArgs, ['--metadata', '-m']), false);
139
144
  const mitmHost = args.mitmHost;
140
145
  const mitmPath = args.mitmPath;
141
146
  const moropoApiKey = args['moropo-v1-api-key'];
@@ -236,10 +241,16 @@ export const cloudCommand = defineCommand({
236
241
  debug,
237
242
  logger: (m) => out(m),
238
243
  });
239
- const REMOVED_MAESTRO_VERSIONS = ['1.39.1', '1.39.2', '1.39.7', '2.0.3', '2.4.0'];
240
- if (REMOVED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
241
- throw new CliError(`Maestro version ${resolvedMaestroVersion} is no longer supported. ` +
242
- `Please upgrade to a newer version. See: https://docs.devicecloud.dev/configuration/maestro-versions`);
244
+ // Soft deprecation notice for Maestro versions slated for removal on
245
+ // 26 June 2026. Non-fatal — these still run during the grace period.
246
+ const DEPRECATED_MAESTRO_VERSIONS = ['1.39.5', '1.41.0'];
247
+ if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) {
248
+ warnOut(ui.warn(colors.bold(`Maestro ${resolvedMaestroVersion} is deprecated`)));
249
+ warnOut(ui.branch([
250
+ `Maestro ${resolvedMaestroVersion} will be removed on 26 June 2026; after that, tests pinned to it will fail.`,
251
+ 'Upgrade to Maestro 2.6.0 or above.',
252
+ `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/configuration/maestro-versions')}`,
253
+ ]));
243
254
  }
244
255
  if (retry !== undefined && retry > 2) {
245
256
  out(ui.warn('Retries are now free of charge but limited to 2. If your test is still failing after 2 retries, please ask for help on Discord.'));
@@ -275,6 +286,13 @@ export const cloudCommand = defineCommand({
275
286
  out(`[DEBUG] Found .app bundle at: ${finalAppFile}`);
276
287
  }
277
288
  }
289
+ // Validate the resolved local app file early — dry-run otherwise skips
290
+ // the upload that would surface a missing file, so a typo'd path would
291
+ // pass a dry-run and only fail on the real run. (URL/.tar.gz inputs are
292
+ // already resolved to existing temp paths by this point.)
293
+ if (!existsSync(finalAppFile)) {
294
+ throw new CliError(`App file does not exist: ${finalAppFile}`);
295
+ }
278
296
  }
279
297
  if (debug) {
280
298
  out(`[DEBUG] First file argument: ${firstFile || 'not provided'}`);
@@ -301,6 +319,18 @@ export const cloudCommand = defineCommand({
301
319
  debug,
302
320
  logger: (m) => out(m),
303
321
  });
322
+ // iOS 16 deprecation notice (soft warning during the grace period;
323
+ // removed on 23 August 2026). Only fires on an explicit --ios-version 16 —
324
+ // when omitted the API defaults to iOS 17, so no false warning.
325
+ const DEPRECATED_IOS_VERSIONS = ['16'];
326
+ if (iOSVersion && DEPRECATED_IOS_VERSIONS.includes(iOSVersion)) {
327
+ warnOut(ui.warn(colors.bold('iOS 16 is deprecated')));
328
+ warnOut(ui.branch([
329
+ 'iOS 16 will be removed on 23 August 2026; after that, tests targeting it will fail.',
330
+ 'Switch to iOS 17 or newer — iPhone 14 also supports 17 and 18.',
331
+ `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/getting-started/devices-configuration')}`,
332
+ ]));
333
+ }
304
334
  deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: (m) => out(m) });
305
335
  if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
306
336
  warnOut('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.');
@@ -385,9 +415,20 @@ export const cloudCommand = defineCommand({
385
415
  ]);
386
416
  // Only log canonical flag keys (skip citty-populated alias duplicates like apiURL/apiUrl).
387
417
  const canonicalFlagKeys = new Set(Object.keys(allFlags));
418
+ // Repeatable flags are recovered from rawArgs (args.* only holds the last
419
+ // occurrence), so echo the fully-collected values rather than args.*.
420
+ const repeatableDisplay = {
421
+ env,
422
+ metadata,
423
+ 'include-tags': includeTags,
424
+ 'exclude-tags': excludeTags,
425
+ 'exclude-flows': excludeFlows,
426
+ };
388
427
  for (const [k, v] of Object.entries(args)) {
389
428
  if (!canonicalFlagKeys.has(k))
390
429
  continue;
430
+ if (k in repeatableDisplay)
431
+ continue;
391
432
  if (v === undefined || v === null || v === false)
392
433
  continue;
393
434
  const asString = String(v);
@@ -395,6 +436,10 @@ export const cloudCommand = defineCommand({
395
436
  flagLogs.push(`${k}: ${asString}`);
396
437
  }
397
438
  }
439
+ for (const [k, values] of Object.entries(repeatableDisplay)) {
440
+ if (values.length > 0)
441
+ flagLogs.push(`${k}: ${values.join(', ')}`);
442
+ }
398
443
  const overridesEntries = Object.entries(flowOverrides);
399
444
  const hasOverrides = overridesEntries.some(([, overrides]) => Object.keys(overrides).length > 0);
400
445
  const submitRows = ui.fields([
@@ -1,5 +1,6 @@
1
1
  import { defineCommand } from 'citty';
2
2
  import { apiFlags } from '../config/flags/api.flags.js';
3
+ import { resolveFrontendUrl } from '../config/environments.js';
3
4
  import { ApiGateway } from '../gateways/api-gateway.js';
4
5
  import { resolveAuth } from '../utils/auth.js';
5
6
  import { CliError, logger, parseIntFlag } from '../utils/cli.js';
@@ -21,8 +22,12 @@ function detectShellExpansion(name) {
21
22
  ' ✗ Incorrect: dcd list --name nightly-*\n');
22
23
  }
23
24
  }
24
- function displayResults(response) {
25
+ function displayResults(response, apiUrl) {
25
26
  const { uploads, total, limit, offset } = response;
27
+ // Build console links from the env the CLI is pointed at, rather than the
28
+ // API-supplied consoleUrl (which is hardcoded to prod) — so dev/staging users
29
+ // get links that actually resolve.
30
+ const frontendUrl = resolveFrontendUrl(apiUrl);
26
31
  if (uploads.length === 0) {
27
32
  logger.log(ui.info('No uploads found matching your criteria.'));
28
33
  return;
@@ -45,7 +50,7 @@ function displayResults(response) {
45
50
  ...ui.fields([
46
51
  ['id', formatId(upload.id)],
47
52
  ['created', formattedDate],
48
- ['console', formatUrl(upload.consoleUrl)],
53
+ ['console', formatUrl(`${frontendUrl}/results?upload=${upload.id}`)],
49
54
  ]),
50
55
  ]));
51
56
  }
@@ -122,7 +127,7 @@ export const listCommand = defineCommand({
122
127
  console.log(JSON.stringify(response, null, 2));
123
128
  return;
124
129
  }
125
- displayResults(response);
130
+ displayResults(response, apiUrl);
126
131
  }
127
132
  catch (error) {
128
133
  throw new CliError(`Failed to list uploads: ${error.message}`);
@@ -1,5 +1,6 @@
1
1
  import { defineCommand } from 'citty';
2
2
  import { apiFlags } from '../config/flags/api.flags.js';
3
+ import { resolveFrontendUrl } from '../config/environments.js';
3
4
  import { ApiGateway } from '../gateways/api-gateway.js';
4
5
  import { formatDurationSeconds } from '../methods.js';
5
6
  import { resolveAuth } from '../utils/auth.js';
@@ -163,8 +164,14 @@ async function statusMain({ apiKeyFlag, apiUrl, json, name, uploadId, }) {
163
164
  if (status.createdAt) {
164
165
  fields.push(['created', formatDateTime(status.createdAt)]);
165
166
  }
166
- if (status.consoleUrl) {
167
- fields.push(['console', formatUrl(status.consoleUrl)]);
167
+ // Prefer a console link built from the env the CLI is pointed at (the
168
+ // API-supplied consoleUrl is hardcoded to prod, so it misdirects
169
+ // dev/staging users); fall back to the API value if we have no uploadId.
170
+ const consoleUrl = status.uploadId
171
+ ? `${resolveFrontendUrl(apiUrl)}/results?upload=${status.uploadId}`
172
+ : status.consoleUrl;
173
+ if (consoleUrl) {
174
+ fields.push(['console', formatUrl(consoleUrl)]);
168
175
  }
169
176
  logger.log(ui.section('Upload Status'));
170
177
  logger.log(ui.branch([ui.status(status.status), ...ui.fields(fields)]));
@@ -38,10 +38,17 @@ export const upgradeCommand = defineCommand({
38
38
  }
39
39
  const current = getCliVersion();
40
40
  const versionService = new VersionService();
41
- const latest = await versionService.checkLatestCliVersion();
42
- if (!latest) {
43
- throw new CliError('Could not reach the update manifest. Check your network connection and try again.');
41
+ const result = await versionService.checkLatestCliVersion(current);
42
+ if (!result.ok) {
43
+ throw new CliError(`Could not reach the update manifest (${result.error}). Check your network connection and try again.`);
44
44
  }
45
+ // Reachable, but nothing published on this channel yet (e.g. a stable
46
+ // install while only betas exist). Not an error — just nothing to do.
47
+ if (result.version === null) {
48
+ logger.log(ui.info(`No newer release available on the ${result.channel} channel.`));
49
+ return;
50
+ }
51
+ const latest = result.version;
45
52
  if (!versionService.isOutdated(current, latest)) {
46
53
  logger.log(ui.success(`Already on the latest version (${colors.highlight(current)})`));
47
54
  return;
@@ -54,7 +61,10 @@ export const upgradeCommand = defineCommand({
54
61
  if (process.platform === 'win32') {
55
62
  // Windows can't replace a running .exe; defer to a re-run of the installer.
56
63
  const base = process.env.DCD_DOWNLOAD_BASE ?? DEFAULT_DOWNLOAD_BASE;
57
- throw new CliError(`Automatic upgrade on Windows is not yet supported. Re-run the installer:\n irm ${base}/install.ps1 | iex`);
64
+ // Prerelease users need the beta channel opt-in or the installer resolves
65
+ // the (currently non-existent) stable release.
66
+ const betaHint = result.channel === 'beta' ? '$env:DCD_BETA=1; ' : '';
67
+ throw new CliError(`Automatic upgrade on Windows is not yet supported. Re-run the installer:\n ${betaHint}irm ${base}/install.ps1 | iex`);
58
68
  }
59
69
  const base = process.env.DCD_DOWNLOAD_BASE ?? DEFAULT_DOWNLOAD_BASE;
60
70
  const binaryUrl = `${base}/download/${latest}/${asset}`;
package/dist/methods.js CHANGED
@@ -676,8 +676,9 @@ async function uploadToBackblaze(uploadUrl, authorizationToken, fileName, source
676
676
  if (debug) {
677
677
  console.error(`[DEBUG] Backblaze upload failed with status ${response.status}: ${errorText}`);
678
678
  }
679
- // Don't throw - we don't want Backblaze failures to block the primary upload
680
- console.warn(`Warning: Backblaze upload failed with status ${response.status}`);
679
+ // Don't throw and don't warn Backblaze is the primary attempt and the
680
+ // Supabase fallback usually recovers. A user-facing error is raised only
681
+ // if every strategy fails (see validateUploadResults).
681
682
  return false;
682
683
  }
683
684
  if (debug) {
@@ -700,12 +701,9 @@ async function uploadToBackblaze(uploadUrl, authorizationToken, fileName, source
700
701
  if (debug) {
701
702
  console.error('[DEBUG] Network error detected - could be DNS, connection, or SSL issue');
702
703
  }
703
- console.warn('Warning: Backblaze upload failed due to network error');
704
- }
705
- else {
706
- // Don't throw - we don't want Backblaze failures to block the primary upload
707
- console.warn(`Warning: Backblaze upload failed: ${error instanceof Error ? error.message : String(error)}`);
708
704
  }
705
+ // Don't throw and don't warn — the Supabase fallback usually recovers, and
706
+ // validateUploadResults raises a user-facing error only if all fail.
709
707
  return false;
710
708
  }
711
709
  }
@@ -801,12 +799,9 @@ function logBackblazeUploadError(error, debug) {
801
799
  console.error(`[DEBUG] Stack trace:\n${error.stack}`);
802
800
  }
803
801
  }
804
- if (error instanceof Error && error.message.includes('network error')) {
805
- console.warn('Warning: Backblaze large file upload failed due to network error');
806
- }
807
- else {
808
- console.warn(`Warning: Backblaze large file upload failed: ${error instanceof Error ? error.message : String(error)}`);
809
- }
802
+ // No user-facing warning: Backblaze is the primary attempt and the Supabase
803
+ // fallback usually recovers; validateUploadResults raises the only
804
+ // user-facing error, and only when every strategy fails.
810
805
  }
811
806
  /**
812
807
  * Upload large file to Backblaze using multi-part upload with streaming (for files >= 5MB)
@@ -104,7 +104,7 @@ export declare class ResultsPollingService {
104
104
  * Build the live footer shown under the status display: whether realtime
105
105
  * updates are connected (for logged-in users) and how long until the next
106
106
  * backstop poll. While a fetch is in flight (`nextPollAt` is null) the
107
- * countdown reads "refreshing…".
107
+ * countdown reads "refreshing…". In quiet mode the countdown is omitted.
108
108
  */
109
109
  private buildStatusFooter;
110
110
  }
@@ -3,6 +3,7 @@ import { ApiGateway } from '../gateways/api-gateway.js';
3
3
  import { RealtimeResultsGateway, } from '../gateways/realtime-gateway.js';
4
4
  import { formatDurationSeconds } from '../methods.js';
5
5
  import { checkInternetConnectivity } from '../utils/connectivity.js';
6
+ import { isCI } from '../utils/ci.js';
6
7
  import { ux } from '../utils/progress.js';
7
8
  import { colors, formatTestSummary, statusPalette, table } from '../utils/styling.js';
8
9
  import { ui } from '../utils/ui.js';
@@ -100,10 +101,18 @@ export class ResultsPollingService {
100
101
  let realtimeEnabled = false;
101
102
  let statusBody = '';
102
103
  let nextPollAt = null;
104
+ // The animated footer/countdown only makes sense on a TTY. In CI/pipes it
105
+ // would flood logs (a fresh line per frame), so we drop it and let the
106
+ // progress adapter print one line per distinct status change instead.
107
+ const interactive = !json && !isCI();
103
108
  const renderStatus = () => {
104
109
  if (json)
105
110
  return;
106
- const footer = this.buildStatusFooter(realtimeEnabled, subscription?.isConnected() ?? false, nextPollAt);
111
+ if (!interactive) {
112
+ ux.action.status = statusBody;
113
+ return;
114
+ }
115
+ const footer = this.buildStatusFooter(realtimeEnabled, subscription?.isConnected() ?? false, nextPollAt, quiet);
107
116
  ux.action.status = footer ? `${statusBody}\n${footer}` : statusBody;
108
117
  };
109
118
  // Realtime is a latency optimisation over the backstop poll; only logged-in
@@ -132,8 +141,11 @@ export class ResultsPollingService {
132
141
  }
133
142
  // Tick the live footer once a second so the countdown actually counts down
134
143
  // (the spinner's own frames don't recompute our message). Unref'd so it
135
- // never keeps the process alive on its own.
136
- const ticker = json ? null : setInterval(renderStatus, 1000);
144
+ // never keeps the process alive on its own. Only on an interactive TTY —
145
+ // a 1s ticker in CI would reprint the status every second.
146
+ const ticker = interactive
147
+ ? setInterval(renderStatus, 1000)
148
+ : null;
137
149
  ticker?.unref?.();
138
150
  try {
139
151
  // Poll in a loop until all tests complete
@@ -446,21 +458,25 @@ export class ResultsPollingService {
446
458
  * Build the live footer shown under the status display: whether realtime
447
459
  * updates are connected (for logged-in users) and how long until the next
448
460
  * backstop poll. While a fetch is in flight (`nextPollAt` is null) the
449
- * countdown reads "refreshing…".
461
+ * countdown reads "refreshing…". In quiet mode the countdown is omitted.
450
462
  */
451
- buildStatusFooter(realtimeEnabled, realtimeConnected, nextPollAt) {
463
+ buildStatusFooter(realtimeEnabled, realtimeConnected, nextPollAt, quiet) {
452
464
  const parts = [];
453
465
  if (realtimeEnabled) {
454
466
  parts.push(realtimeConnected
455
467
  ? colors.success('● realtime connected')
456
468
  : colors.warning('○ realtime connecting…'));
457
469
  }
458
- if (nextPollAt === null) {
459
- parts.push(colors.dim('refreshing…'));
460
- }
461
- else {
462
- const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000));
463
- parts.push(colors.dim(`next refresh in ${secondsLeft}s`));
470
+ // The countdown to the next backstop poll is noise in quiet mode (geared at
471
+ // CI), so suppress it there while keeping the realtime indicator.
472
+ if (!quiet) {
473
+ if (nextPollAt === null) {
474
+ parts.push(colors.dim('refreshing…'));
475
+ }
476
+ else {
477
+ const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000));
478
+ parts.push(colors.dim(`next refresh in ${secondsLeft}s`));
479
+ }
464
480
  }
465
481
  return parts.join(colors.dim(' · '));
466
482
  }
@@ -1,4 +1,19 @@
1
1
  import { CompatibilityData } from '../utils/compatibility.js';
2
+ export type ReleaseChannel = 'beta' | 'stable';
3
+ /**
4
+ * Outcome of a release-manifest lookup. `ok: true` means the manifest was
5
+ * reachable — `version` is the published version on the channel, or `null` when
6
+ * nothing is published there yet. `ok: false` means the lookup itself failed
7
+ * (network/timeout/non-2xx).
8
+ */
9
+ export type LatestVersionResult = {
10
+ ok: true;
11
+ channel: ReleaseChannel;
12
+ version: null | string;
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ };
2
17
  /**
3
18
  * Service for handling version validation and checking
4
19
  */
@@ -6,14 +21,22 @@ export declare class VersionService {
6
21
  /**
7
22
  * Fetch the latest published CLI version from the release manifest.
8
23
  * Works for both npm- and binary-installed users (no `npm` shell-out).
9
- * Silently returns null on any failure — this check is informational only.
24
+ *
25
+ * The result is discriminated so callers can tell "reachable, but no release
26
+ * on this channel yet" (`ok: true, version: null`) apart from an actual
27
+ * network/manifest failure (`ok: false`) — the old single-`null` return
28
+ * conflated the two and produced a misleading "check your network" error
29
+ * during the beta. Prerelease installs (current version contains `-`) query
30
+ * the opt-in beta channel; everyone else gets the stable channel.
10
31
  */
11
- checkLatestCliVersion(): Promise<null | string>;
32
+ checkLatestCliVersion(currentVersion?: string): Promise<LatestVersionResult>;
12
33
  /**
13
- * Compare two semantic version strings
14
- * @param current - Current version
15
- * @param latest - Latest version
16
- * @returns true if current is older than latest
34
+ * Compare two semantic version strings (SemVer 2.0.0 precedence, including
35
+ * prerelease tags). Returns true if `current` is strictly older than `latest`.
36
+ *
37
+ * Prerelease handling matters here: a beta-to-beta bump such as
38
+ * "5.0.0-beta.0" -> "5.0.0-beta.1" shares the same major.minor.patch, so we
39
+ * must compare the prerelease identifiers to detect that an upgrade exists.
17
40
  */
18
41
  isOutdated(current: string, latest: string): boolean;
19
42
  /**
@@ -1,5 +1,59 @@
1
1
  const DEFAULT_MANIFEST_URL = 'https://get.devicecloud.dev/latest.json';
2
2
  const MANIFEST_TIMEOUT_MS = 3000;
3
+ /**
4
+ * Compare two semantic versions per SemVer 2.0.0 precedence rules.
5
+ * Returns a negative number if `a < b`, positive if `a > b`, and 0 if equal.
6
+ *
7
+ * Implements the prerelease rules that the previous naive comparator dropped:
8
+ * - A version WITH a prerelease has lower precedence than the same version
9
+ * without one ("1.0.0-beta" < "1.0.0").
10
+ * - Prerelease identifiers are compared dot-separated, left to right:
11
+ * numeric identifiers compare numerically, alphanumeric ones compare
12
+ * lexically (ASCII), and numeric always sorts below alphanumeric. A longer
13
+ * set of identifiers wins when all preceding ones are equal.
14
+ */
15
+ function compareSemver(a, b) {
16
+ const split = (v) => {
17
+ const [core, ...preParts] = v.trim().replace(/^v/, '').split('-');
18
+ const nums = core.split('.').map((n) => Number(n) || 0);
19
+ const pre = preParts.join('-');
20
+ return {
21
+ release: [nums[0] || 0, nums[1] || 0, nums[2] || 0],
22
+ pre: pre ? pre.split('.') : [],
23
+ };
24
+ };
25
+ const left = split(a);
26
+ const right = split(b);
27
+ for (let i = 0; i < 3; i++) {
28
+ if (left.release[i] !== right.release[i]) {
29
+ return left.release[i] - right.release[i];
30
+ }
31
+ }
32
+ // Equal release: a version with no prerelease outranks one that has it.
33
+ if (left.pre.length === 0 && right.pre.length === 0)
34
+ return 0;
35
+ if (left.pre.length === 0)
36
+ return 1;
37
+ if (right.pre.length === 0)
38
+ return -1;
39
+ const len = Math.min(left.pre.length, right.pre.length);
40
+ for (let i = 0; i < len; i++) {
41
+ const lp = left.pre[i];
42
+ const rp = right.pre[i];
43
+ if (lp === rp)
44
+ continue;
45
+ const ln = /^\d+$/.test(lp);
46
+ const rn = /^\d+$/.test(rp);
47
+ if (ln && rn)
48
+ return Number(lp) - Number(rp);
49
+ if (ln)
50
+ return -1; // numeric identifiers sort below alphanumeric
51
+ if (rn)
52
+ return 1;
53
+ return lp < rp ? -1 : 1;
54
+ }
55
+ return left.pre.length - right.pre.length;
56
+ }
3
57
  /**
4
58
  * Service for handling version validation and checking
5
59
  */
@@ -7,48 +61,54 @@ export class VersionService {
7
61
  /**
8
62
  * Fetch the latest published CLI version from the release manifest.
9
63
  * Works for both npm- and binary-installed users (no `npm` shell-out).
10
- * Silently returns null on any failure — this check is informational only.
64
+ *
65
+ * The result is discriminated so callers can tell "reachable, but no release
66
+ * on this channel yet" (`ok: true, version: null`) apart from an actual
67
+ * network/manifest failure (`ok: false`) — the old single-`null` return
68
+ * conflated the two and produced a misleading "check your network" error
69
+ * during the beta. Prerelease installs (current version contains `-`) query
70
+ * the opt-in beta channel; everyone else gets the stable channel.
11
71
  */
12
- async checkLatestCliVersion() {
13
- const url = process.env.DCD_MANIFEST_URL ?? DEFAULT_MANIFEST_URL;
72
+ async checkLatestCliVersion(currentVersion) {
73
+ const channel = currentVersion?.includes('-') ? 'beta' : 'stable';
74
+ const base = process.env.DCD_MANIFEST_URL ?? DEFAULT_MANIFEST_URL;
75
+ const url = channel === 'beta'
76
+ ? `${base}${base.includes('?') ? '&' : '?'}channel=beta`
77
+ : base;
14
78
  const controller = new AbortController();
15
79
  const timer = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS);
16
80
  try {
17
81
  const res = await fetch(url, { signal: controller.signal });
18
- if (!res.ok)
19
- return null;
82
+ if (!res.ok) {
83
+ return { ok: false, error: `manifest responded with HTTP ${res.status}` };
84
+ }
20
85
  const data = (await res.json());
21
- return typeof data.version === 'string' ? data.version : null;
86
+ return {
87
+ ok: true,
88
+ channel,
89
+ version: typeof data.version === 'string' ? data.version : null,
90
+ };
22
91
  }
23
- catch {
24
- return null;
92
+ catch (error) {
93
+ return {
94
+ ok: false,
95
+ error: error instanceof Error ? error.message : String(error),
96
+ };
25
97
  }
26
98
  finally {
27
99
  clearTimeout(timer);
28
100
  }
29
101
  }
30
102
  /**
31
- * Compare two semantic version strings
32
- * @param current - Current version
33
- * @param latest - Latest version
34
- * @returns true if current is older than latest
103
+ * Compare two semantic version strings (SemVer 2.0.0 precedence, including
104
+ * prerelease tags). Returns true if `current` is strictly older than `latest`.
105
+ *
106
+ * Prerelease handling matters here: a beta-to-beta bump such as
107
+ * "5.0.0-beta.0" -> "5.0.0-beta.1" shares the same major.minor.patch, so we
108
+ * must compare the prerelease identifiers to detect that an upgrade exists.
35
109
  */
36
110
  isOutdated(current, latest) {
37
- // Strip any prerelease suffix ("1.2.3-beta.1" -> "1.2.3") and default
38
- // missing segments to 0 so short/prerelease versions still compare.
39
- const parts = (version) => {
40
- const nums = version.split('-')[0].split('.').map(Number);
41
- return [nums[0] || 0, nums[1] || 0, nums[2] || 0];
42
- };
43
- const currentParts = parts(current);
44
- const latestParts = parts(latest);
45
- for (let i = 0; i < 3; i++) {
46
- if (currentParts[i] < latestParts[i])
47
- return true;
48
- if (currentParts[i] > latestParts[i])
49
- return false;
50
- }
51
- return false;
111
+ return compareSemver(current, latest) < 0;
52
112
  }
53
113
  /**
54
114
  * Resolve and validate Maestro version against API compatibility data
@@ -24,10 +24,24 @@ export declare function validateEnum<T extends string>(value: string | undefined
24
24
  /**
25
25
  * Coerce a flag value (possibly a single string, array, or undefined) into a
26
26
  * flat string array. Comma-separated values inside each entry are split out.
27
- * Used for repeatable flags like --include-tags, --env, --metadata where citty
28
- * surfaces a string (single use) or string[] (repeated).
27
+ * Pair with {@link collectRepeatedFlag} for repeatable flags.
29
28
  */
30
29
  export declare function coerceArray(value: string | string[] | undefined, split?: boolean): string[];
30
+ /**
31
+ * Collect every occurrence of a repeatable flag from raw argv, in order.
32
+ *
33
+ * citty 0.2.2 delegates to Node's `parseArgs`, which — without `multiple: true`
34
+ * (unsupported by citty's ArgsDef) — keeps only the LAST value of a repeated
35
+ * `type: 'string'` flag. So `-e A=1 -e B=2` collapses to just `B=2`. We recover
36
+ * all occurrences by scanning rawArgs ourselves (same approach as
37
+ * `recoverFlagValue` in commands/live.ts).
38
+ *
39
+ * `names` lists every spelling of one logical flag, e.g. ['--env', '-e'].
40
+ * Handles both `--flag value` (consuming the next token, so values starting
41
+ * with `-` survive) and `--flag=value`. Feed the result through
42
+ * {@link coerceArray} for comma-splitting where appropriate.
43
+ */
44
+ export declare function collectRepeatedFlag(rawArgs: string[], names: string[]): string[];
31
45
  /**
32
46
  * Parse an integer flag. Returns undefined if the value is undefined/empty.
33
47
  * Throws CliError if the value is not a valid integer.
package/dist/utils/cli.js CHANGED
@@ -9,9 +9,17 @@
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { telemetry } from '../services/telemetry.service.js';
11
11
  import { symbols } from './styling.js';
12
- // Resolve version at runtime read the file rather than importing it, so
13
- // package.json never gets pulled into the tsc program / dist rootDir.
12
+ // Resolve version at runtime. The bun-compiled binary can't read package.json
13
+ // off disk (it isn't bundled next to the embedded module), so the build stamps
14
+ // the version in via `bun --define __DCD_CLI_VERSION__` (see
15
+ // scripts/build-binaries.mjs). Prefer that constant; on the npm/tsx path the
16
+ // identifier was never defined, so `typeof` is 'undefined' (no ReferenceError)
17
+ // and we fall back to reading package.json.
14
18
  export function getCliVersion() {
19
+ if (typeof __DCD_CLI_VERSION__ === 'string' &&
20
+ __DCD_CLI_VERSION__.length > 0) {
21
+ return __DCD_CLI_VERSION__;
22
+ }
15
23
  try {
16
24
  const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
17
25
  return pkg.version;
@@ -90,8 +98,7 @@ export function validateEnum(value, allowed, flagName) {
90
98
  /**
91
99
  * Coerce a flag value (possibly a single string, array, or undefined) into a
92
100
  * flat string array. Comma-separated values inside each entry are split out.
93
- * Used for repeatable flags like --include-tags, --env, --metadata where citty
94
- * surfaces a string (single use) or string[] (repeated).
101
+ * Pair with {@link collectRepeatedFlag} for repeatable flags.
95
102
  */
96
103
  export function coerceArray(value, split = true) {
97
104
  if (value === undefined)
@@ -101,6 +108,36 @@ export function coerceArray(value, split = true) {
101
108
  return arr;
102
109
  return arr.flatMap((v) => v.split(','));
103
110
  }
111
+ /**
112
+ * Collect every occurrence of a repeatable flag from raw argv, in order.
113
+ *
114
+ * citty 0.2.2 delegates to Node's `parseArgs`, which — without `multiple: true`
115
+ * (unsupported by citty's ArgsDef) — keeps only the LAST value of a repeated
116
+ * `type: 'string'` flag. So `-e A=1 -e B=2` collapses to just `B=2`. We recover
117
+ * all occurrences by scanning rawArgs ourselves (same approach as
118
+ * `recoverFlagValue` in commands/live.ts).
119
+ *
120
+ * `names` lists every spelling of one logical flag, e.g. ['--env', '-e'].
121
+ * Handles both `--flag value` (consuming the next token, so values starting
122
+ * with `-` survive) and `--flag=value`. Feed the result through
123
+ * {@link coerceArray} for comma-splitting where appropriate.
124
+ */
125
+ export function collectRepeatedFlag(rawArgs, names) {
126
+ const out = [];
127
+ for (let i = 0; i < rawArgs.length; i++) {
128
+ const arg = rawArgs[i];
129
+ const eqName = names.find((n) => arg.startsWith(`${n}=`));
130
+ if (eqName) {
131
+ out.push(arg.slice(eqName.length + 1));
132
+ continue;
133
+ }
134
+ if (names.includes(arg) && i + 1 < rawArgs.length) {
135
+ out.push(rawArgs[i + 1]);
136
+ i++; // consume the value so a leading-dash value isn't re-read as a flag
137
+ }
138
+ }
139
+ return out;
140
+ }
104
141
  /**
105
142
  * Parse an integer flag. Returns undefined if the value is undefined/empty.
106
143
  * Throws CliError if the value is not a valid integer.
@@ -1,10 +1,13 @@
1
1
  declare class Action {
2
2
  private current;
3
3
  private _status;
4
+ private _lastPrinted;
5
+ private interactive;
4
6
  start(title: string, initialStatus?: string, _opts?: unknown): void;
5
7
  stop(message?: string): void;
6
8
  set status(value: string);
7
9
  get status(): string;
10
+ private print;
8
11
  }
9
12
  export declare const ux: {
10
13
  action: Action;
@@ -3,20 +3,47 @@
3
3
  * drop-in API for existing services that used oclif's `ux.action` / `ux.info`.
4
4
  *
5
5
  * Keeps call sites unchanged while migrating away from @oclif/core.
6
+ *
7
+ * TTY-awareness: @clack/prompts' spinner animates on a timer and, when stdout
8
+ * isn't a TTY (CI, pipes, redirects), it can't rewrite a line in place — every
9
+ * frame becomes a fresh line, flooding logs with hundreds of duplicates. In
10
+ * non-interactive environments we skip the spinner entirely and instead print a
11
+ * plain line once per *distinct* status, so CI logs show real progress without
12
+ * the flood.
6
13
  */
7
14
  import * as p from '@clack/prompts';
15
+ import { isCI } from './ci.js';
8
16
  class Action {
9
17
  current = null;
10
18
  _status = '';
19
+ // Last line emitted in non-interactive mode, for de-duplication.
20
+ _lastPrinted = '';
21
+ interactive() {
22
+ return process.stdout.isTTY === true && !isCI();
23
+ }
11
24
  start(title, initialStatus, _opts) {
25
+ this._status = initialStatus ?? '';
26
+ const line = initialStatus ? `${title} — ${initialStatus}` : title;
27
+ if (!this.interactive()) {
28
+ this.current = null;
29
+ this.print(line);
30
+ return;
31
+ }
12
32
  if (this.current) {
13
33
  this.current.stop();
14
34
  }
15
35
  this.current = p.spinner();
16
- this._status = initialStatus ?? '';
17
- this.current.start(initialStatus ? `${title} — ${initialStatus}` : title);
36
+ this.current.start(line);
18
37
  }
19
38
  stop(message) {
39
+ if (!this.interactive()) {
40
+ if (message)
41
+ this.print(message);
42
+ this.current = null;
43
+ this._status = '';
44
+ this._lastPrinted = '';
45
+ return;
46
+ }
20
47
  if (!this.current) {
21
48
  if (message) {
22
49
  // eslint-disable-next-line no-console
@@ -30,13 +57,28 @@ class Action {
30
57
  }
31
58
  set status(value) {
32
59
  this._status = value;
33
- if (this.current && value) {
60
+ if (!value)
61
+ return;
62
+ if (!this.interactive()) {
63
+ this.print(value);
64
+ return;
65
+ }
66
+ if (this.current) {
34
67
  this.current.message(value);
35
68
  }
36
69
  }
37
70
  get status() {
38
71
  return this._status;
39
72
  }
73
+ // Emit a line only when it differs from the previous one, so repeated polls
74
+ // with no state change stay quiet.
75
+ print(line) {
76
+ if (line === this._lastPrinted)
77
+ return;
78
+ this._lastPrinted = line;
79
+ // eslint-disable-next-line no-console
80
+ console.log(line);
81
+ }
40
82
  }
41
83
  export const ux = {
42
84
  action: new Action(),
package/package.json CHANGED
@@ -60,7 +60,7 @@
60
60
  "type": "git",
61
61
  "url": "https://devicecloud.dev"
62
62
  },
63
- "version": "5.0.0-beta.1",
63
+ "version": "5.0.0-beta.2",
64
64
  "bugs": {
65
65
  "url": "https://discord.gg/gm3mJwcNw8"
66
66
  },