@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 +21 -0
- package/README.md +17 -0
- package/dist/commands/cloud.js +59 -14
- package/dist/commands/list.js +8 -3
- package/dist/commands/status.js +9 -2
- package/dist/commands/upgrade.js +14 -4
- package/dist/methods.js +8 -13
- package/dist/services/results-polling.service.d.ts +1 -1
- package/dist/services/results-polling.service.js +27 -11
- package/dist/services/version.service.d.ts +29 -6
- package/dist/services/version.service.js +87 -27
- package/dist/utils/cli.d.ts +16 -2
- package/dist/utils/cli.js +41 -4
- package/dist/utils/progress.d.ts +3 -0
- package/dist/utils/progress.js +45 -3
- package/package.json +1 -1
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
|
+
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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
|
|
82
|
-
if (
|
|
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(
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
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(
|
|
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(
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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([
|
package/dist/commands/list.js
CHANGED
|
@@ -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.
|
|
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}`);
|
package/dist/commands/status.js
CHANGED
|
@@ -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
|
-
|
|
167
|
-
|
|
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)]));
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -38,10 +38,17 @@ export const upgradeCommand = defineCommand({
|
|
|
38
38
|
}
|
|
39
39
|
const current = getCliVersion();
|
|
40
40
|
const versionService = new VersionService();
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
43
|
-
throw new CliError(
|
|
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
|
-
|
|
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
|
|
680
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
*
|
|
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<
|
|
32
|
+
checkLatestCliVersion(currentVersion?: string): Promise<LatestVersionResult>;
|
|
12
33
|
/**
|
|
13
|
-
* Compare two semantic version strings
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
channel,
|
|
89
|
+
version: typeof data.version === 'string' ? data.version : null,
|
|
90
|
+
};
|
|
22
91
|
}
|
|
23
|
-
catch {
|
|
24
|
-
return
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
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
|
package/dist/utils/cli.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
13
|
-
//
|
|
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
|
-
*
|
|
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.
|
package/dist/utils/progress.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/progress.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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(),
|