@devicecloud.dev/dcd 5.0.0-beta.2 → 5.0.0-beta.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.
@@ -16,9 +16,10 @@ import { TestSubmissionService } from '../services/test-submission.service.js';
16
16
  import { VersionService } from '../services/version.service.js';
17
17
  import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, } from '../types/domain/device.types.js';
18
18
  import { resolveAuth } from '../utils/auth.js';
19
- import { isCI } from '../utils/ci.js';
19
+ import { detectCiContext, isCI } from '../utils/ci.js';
20
20
  import { CliError, coerceArray, collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
21
21
  import { fetchCompatibilityData, } from '../utils/compatibility.js';
22
+ import { renderNotices } from '../services/notices.service.js';
22
23
  import { resolveApiUrl } from '../utils/config-store.js';
23
24
  import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
24
25
  import { colors, formatId, formatUrl, getConsoleUrl, } from '../utils/styling.js';
@@ -220,9 +221,14 @@ export const cloudCommand = defineCommand({
220
221
  if (auth.mode === 'apiKey' && !json && !quiet && !isCI()) {
221
222
  out(colors.dim('Tip: run `dcd login` for live test updates and a smoother experience than passing --api-key.'));
222
223
  }
224
+ const ciContext = detectCiContext();
223
225
  let compatibilityData;
224
226
  try {
225
- compatibilityData = await fetchCompatibilityData(apiUrl, auth);
227
+ compatibilityData = await fetchCompatibilityData(apiUrl, auth, {
228
+ cliVersion,
229
+ ciProvider: ciContext.provider,
230
+ ciWrapperVersion: ciContext.wrapperVersion,
231
+ });
226
232
  if (debug) {
227
233
  out('[DEBUG] Successfully fetched compatibility data from API');
228
234
  }
@@ -241,17 +247,6 @@ export const cloudCommand = defineCommand({
241
247
  debug,
242
248
  logger: (m) => out(m),
243
249
  });
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
- ]));
254
- }
255
250
  if (retry !== undefined && retry > 2) {
256
251
  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.'));
257
252
  retry = 2;
@@ -319,18 +314,17 @@ export const cloudCommand = defineCommand({
319
314
  debug,
320
315
  logger: (m) => out(m),
321
316
  });
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
- }
317
+ // Render DB-driven notices (deprecation/warn/info/marketing) the API
318
+ // returned with the compatibility data. Replaces the previously hardcoded
319
+ // iOS-16 deprecation warning — that is now a seeded notice gated on the
320
+ // selected iOS version below. Honours --json via out/warnOut.
321
+ renderNotices(compatibilityData.notices, {
322
+ ios_version: iOSVersion,
323
+ android_api_level: androidApiLevel,
324
+ cli_version: cliVersion,
325
+ ci_provider: ciContext.provider,
326
+ ci_wrapper_version: ciContext.wrapperVersion,
327
+ }, { out, warnOut });
334
328
  deviceValidationService.validateAndroidDevice(androidApiLevel, androidDevice, googlePlay, compatibilityData, { debug, logger: (m) => out(m) });
335
329
  if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) {
336
330
  warnOut('The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.');
@@ -140,6 +140,11 @@ const startSub = defineCommand({
140
140
  if (Boolean(androidDevice) !== Boolean(androidApiLevel)) {
141
141
  throw new CliError('--android-device and --android-api-level must be provided together.');
142
142
  }
143
+ logger.log(ui.warn(colors.bold('Live is in beta')));
144
+ logger.log(ui.branch([
145
+ 'Live device sessions are a beta feature, billed at $0.03/min.',
146
+ `${colors.dim('Not enrolled?')} Contact support to request access.`,
147
+ ]));
143
148
  logger.log(ui.running(`Starting ${platform} live session…`));
144
149
  const session = await ApiGateway.startLiveSession(apiUrl, auth, {
145
150
  binaryUploadId: binaryId,
@@ -0,0 +1,40 @@
1
+ export type NoticeLevel = 'deprecation' | 'warn' | 'info' | 'marketing';
2
+ export interface NoticeMatchRule {
3
+ field: string;
4
+ op: 'present' | 'absent' | 'equals' | 'in' | 'not_in' | 'lt' | 'gt';
5
+ value?: unknown;
6
+ }
7
+ export interface NoticeMatch {
8
+ rules: NoticeMatchRule[];
9
+ }
10
+ /**
11
+ * The client-facing notice shape returned by the API (embedded in the
12
+ * compatibility response and from GET /notices). `match` is an optional display
13
+ * gate the CLI evaluates locally against its own context (e.g. the selected iOS
14
+ * version) — the API can't know those at fetch time.
15
+ */
16
+ export interface Notice {
17
+ id: string;
18
+ slug: string | null;
19
+ level: NoticeLevel;
20
+ title: string;
21
+ body: string;
22
+ learnMoreUrl: string | null;
23
+ dismissible: boolean;
24
+ match: NoticeMatch | null;
25
+ }
26
+ /** Flat key/value context the `match` DSL is evaluated against. */
27
+ export type NoticeContext = Record<string, unknown>;
28
+ /** A null / empty match imposes no constraint; otherwise every rule must match (AND). */
29
+ export declare function matchesRules(match: NoticeMatch | null | undefined, ctx: NoticeContext): boolean;
30
+ export interface RenderNoticesOptions {
31
+ out: (message: string) => void;
32
+ warnOut: (message: string) => void;
33
+ }
34
+ /**
35
+ * Filter notices by their local-context `match` gate and render those that pass.
36
+ * Returns the visible notices so a `--json` caller can include them in its
37
+ * payload instead of printing. When `--json` is active, callers pass no-op
38
+ * out/warnOut so nothing is printed but the list is still returned.
39
+ */
40
+ export declare function renderNotices(notices: Notice[] | undefined, ctx: NoticeContext, opts: RenderNoticesOptions): Notice[];
@@ -0,0 +1,80 @@
1
+ import { ui } from '../utils/ui.js';
2
+ import { colors } from '../utils/styling.js';
3
+ import { compareSemver } from './version.service.js';
4
+ function isPresent(raw) {
5
+ return raw !== null && raw !== undefined && raw !== false && raw !== '';
6
+ }
7
+ function ruleMatches(rule, ctx) {
8
+ const raw = ctx[rule.field];
9
+ switch (rule.op) {
10
+ case 'present':
11
+ return isPresent(raw);
12
+ case 'absent':
13
+ return !isPresent(raw);
14
+ case 'equals':
15
+ return isPresent(raw) && String(raw) === String(rule.value);
16
+ case 'in':
17
+ return (isPresent(raw) &&
18
+ Array.isArray(rule.value) &&
19
+ rule.value.map(String).includes(String(raw)));
20
+ case 'not_in':
21
+ return (isPresent(raw) &&
22
+ Array.isArray(rule.value) &&
23
+ !rule.value.map(String).includes(String(raw)));
24
+ case 'lt':
25
+ return (isPresent(raw) &&
26
+ rule.value != null &&
27
+ compareSemver(String(raw), String(rule.value)) < 0);
28
+ case 'gt':
29
+ return (isPresent(raw) &&
30
+ rule.value != null &&
31
+ compareSemver(String(raw), String(rule.value)) > 0);
32
+ default:
33
+ return false;
34
+ }
35
+ }
36
+ /** A null / empty match imposes no constraint; otherwise every rule must match (AND). */
37
+ export function matchesRules(match, ctx) {
38
+ if (!match || !Array.isArray(match.rules) || match.rules.length === 0) {
39
+ return true;
40
+ }
41
+ return match.rules.every((rule) => ruleMatches(rule, ctx));
42
+ }
43
+ /** Render a single notice with styling appropriate to its level. */
44
+ function renderNotice(notice, opts) {
45
+ const rows = [notice.body];
46
+ if (notice.learnMoreUrl) {
47
+ rows.push(`${colors.dim('See:')} ${colors.url(notice.learnMoreUrl)}`);
48
+ }
49
+ switch (notice.level) {
50
+ case 'deprecation':
51
+ case 'warn':
52
+ opts.warnOut(ui.warn(colors.bold(notice.title)));
53
+ opts.warnOut(ui.branch(rows));
54
+ break;
55
+ case 'marketing':
56
+ opts.out(ui.section(notice.title));
57
+ opts.out(ui.branch(rows));
58
+ break;
59
+ case 'info':
60
+ default:
61
+ opts.out(ui.info(colors.bold(notice.title)));
62
+ opts.out(ui.branch(rows));
63
+ break;
64
+ }
65
+ }
66
+ /**
67
+ * Filter notices by their local-context `match` gate and render those that pass.
68
+ * Returns the visible notices so a `--json` caller can include them in its
69
+ * payload instead of printing. When `--json` is active, callers pass no-op
70
+ * out/warnOut so nothing is printed but the list is still returned.
71
+ */
72
+ export function renderNotices(notices, ctx, opts) {
73
+ if (!notices || notices.length === 0)
74
+ return [];
75
+ const visible = notices.filter((n) => matchesRules(n.match, ctx));
76
+ for (const notice of visible) {
77
+ renderNotice(notice, opts);
78
+ }
79
+ return visible;
80
+ }
@@ -14,6 +14,19 @@ export type LatestVersionResult = {
14
14
  ok: false;
15
15
  error: string;
16
16
  };
17
+ /**
18
+ * Compare two semantic versions per SemVer 2.0.0 precedence rules.
19
+ * Returns a negative number if `a < b`, positive if `a > b`, and 0 if equal.
20
+ *
21
+ * Implements the prerelease rules that the previous naive comparator dropped:
22
+ * - A version WITH a prerelease has lower precedence than the same version
23
+ * without one ("1.0.0-beta" < "1.0.0").
24
+ * - Prerelease identifiers are compared dot-separated, left to right:
25
+ * numeric identifiers compare numerically, alphanumeric ones compare
26
+ * lexically (ASCII), and numeric always sorts below alphanumeric. A longer
27
+ * set of identifiers wins when all preceding ones are equal.
28
+ */
29
+ export declare function compareSemver(a: string, b: string): number;
17
30
  /**
18
31
  * Service for handling version validation and checking
19
32
  */
@@ -12,7 +12,7 @@ const MANIFEST_TIMEOUT_MS = 3000;
12
12
  * lexically (ASCII), and numeric always sorts below alphanumeric. A longer
13
13
  * set of identifiers wins when all preceding ones are equal.
14
14
  */
15
- function compareSemver(a, b) {
15
+ export function compareSemver(a, b) {
16
16
  const split = (v) => {
17
17
  const [core, ...preParts] = v.trim().replace(/^v/, '').split('-');
18
18
  const nums = core.split('.').map((n) => Number(n) || 0);
@@ -10,3 +10,18 @@
10
10
  * counts — providers set `CI=true`, but a bare presence check is the safe net.
11
11
  */
12
12
  export declare function isCI(): boolean;
13
+ /** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */
14
+ export interface CiContext {
15
+ /** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */
16
+ provider: string | null;
17
+ /** The wrapper's own version, if it forwarded one (DCD_CI_WRAPPER_VERSION). */
18
+ wrapperVersion: string | null;
19
+ }
20
+ /**
21
+ * Resolve the CI integration context the CLI forwards to the notices API so
22
+ * notices can target a specific integration/version (e.g. "Bitbucket Pipe <
23
+ * 1.1.0"). The DCD CI wrappers set `DCD_CI_PROVIDER` / `DCD_CI_WRAPPER_VERSION`
24
+ * explicitly (preferred — carries the wrapper version); otherwise the provider
25
+ * is inferred from native env vars and the version is unknown.
26
+ */
27
+ export declare function detectCiContext(): CiContext;
package/dist/utils/ci.js CHANGED
@@ -37,3 +37,35 @@ export function isCI() {
37
37
  }
38
38
  return !process.stdout.isTTY;
39
39
  }
40
+ /** Infer the CI provider from the env vars each platform sets natively. */
41
+ function inferProvider() {
42
+ const env = process.env;
43
+ if (env.GITHUB_ACTIONS)
44
+ return 'github';
45
+ if (env.BITRISE_IO || env.BITRISE_BUILD_NUMBER)
46
+ return 'bitrise';
47
+ if (env.BITBUCKET_BUILD_NUMBER)
48
+ return 'bitbucket';
49
+ if (env.EAS_BUILD || env.EAS_BUILD_RUNNER || env.EAS_BUILD_ID)
50
+ return 'eas';
51
+ if (env.GITLAB_CI)
52
+ return 'gitlab';
53
+ if (env.CIRCLECI)
54
+ return 'circleci';
55
+ return null;
56
+ }
57
+ /**
58
+ * Resolve the CI integration context the CLI forwards to the notices API so
59
+ * notices can target a specific integration/version (e.g. "Bitbucket Pipe <
60
+ * 1.1.0"). The DCD CI wrappers set `DCD_CI_PROVIDER` / `DCD_CI_WRAPPER_VERSION`
61
+ * explicitly (preferred — carries the wrapper version); otherwise the provider
62
+ * is inferred from native env vars and the version is unknown.
63
+ */
64
+ export function detectCiContext() {
65
+ const forwardedProvider = process.env.DCD_CI_PROVIDER?.trim();
66
+ const forwardedVersion = process.env.DCD_CI_WRAPPER_VERSION?.trim();
67
+ return {
68
+ provider: forwardedProvider || inferProvider(),
69
+ wrapperVersion: forwardedVersion || null,
70
+ };
71
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AuthContext } from '../types/domain/auth.types.js';
2
+ import type { Notice } from '../services/notices.service.js';
2
3
  export interface CompatibilityData {
3
4
  android: Record<string, string[]>;
4
5
  androidPlay: Record<string, string[]>;
@@ -8,6 +9,14 @@ export interface CompatibilityData {
8
9
  latestVersion: string;
9
10
  supportedVersions: string[];
10
11
  };
12
+ /** Active CLI notices, piggybacked onto the compatibility response by the API. */
13
+ notices?: Notice[];
11
14
  }
12
- export declare function fetchCompatibilityData(apiUrl: string, auth: AuthContext): Promise<CompatibilityData>;
15
+ /** Identity the CLI forwards so the API can target notices by version / CI. */
16
+ export interface ClientContext {
17
+ cliVersion?: string;
18
+ ciProvider?: string | null;
19
+ ciWrapperVersion?: string | null;
20
+ }
21
+ export declare function fetchCompatibilityData(apiUrl: string, auth: AuthContext, clientContext?: ClientContext): Promise<CompatibilityData>;
13
22
  export declare function clearCompatibilityCache(): void;
@@ -1,13 +1,25 @@
1
1
  let cachedCompatibilityData = null;
2
- export async function fetchCompatibilityData(apiUrl, auth) {
2
+ export async function fetchCompatibilityData(apiUrl, auth, clientContext) {
3
3
  if (cachedCompatibilityData) {
4
4
  return cachedCompatibilityData;
5
5
  }
6
+ // Forward CLI / CI identity so the API can version-filter and target notices.
7
+ const noticeHeaders = {};
8
+ if (clientContext?.cliVersion) {
9
+ noticeHeaders['x-dcd-cli-version'] = clientContext.cliVersion;
10
+ }
11
+ if (clientContext?.ciProvider) {
12
+ noticeHeaders['x-dcd-ci-provider'] = clientContext.ciProvider;
13
+ }
14
+ if (clientContext?.ciWrapperVersion) {
15
+ noticeHeaders['x-dcd-ci-wrapper-version'] = clientContext.ciWrapperVersion;
16
+ }
6
17
  try {
7
18
  const response = await fetch(`${apiUrl}/results/compatibility/data`, {
8
19
  headers: {
9
20
  'Content-Type': 'application/json',
10
21
  ...auth.headers,
22
+ ...noticeHeaders,
11
23
  },
12
24
  method: 'GET',
13
25
  });
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.2",
63
+ "version": "5.0.0-beta.4",
64
64
  "bugs": {
65
65
  "url": "https://discord.gg/gm3mJwcNw8"
66
66
  },