Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
EiOSVersions,
} from '../types/domain/device.types.js';
import { resolveAuth } from '../utils/auth.js';
import { isCI } from '../utils/ci.js';
import { detectCiContext, isCI } from '../utils/ci.js';
import {
CliError,
coerceArray,
Expand All @@ -48,6 +48,7 @@ import {
CompatibilityData,
fetchCompatibilityData,
} from '../utils/compatibility.js';
import { renderNotices } from '../services/notices.service.js';
import { resolveApiUrl } from '../utils/config-store.js';
import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js';
import {
Expand Down Expand Up @@ -346,9 +347,14 @@ export const cloudCommand = defineCommand({
);
}

const ciContext = detectCiContext();
let compatibilityData: CompatibilityData;
try {
compatibilityData = await fetchCompatibilityData(apiUrl, auth);
compatibilityData = await fetchCompatibilityData(apiUrl, auth, {
cliVersion,
ciProvider: ciContext.provider,
ciWrapperVersion: ciContext.wrapperVersion,
});
if (debug) {
out('[DEBUG] Successfully fetched compatibility data from API');
}
Expand Down Expand Up @@ -472,20 +478,21 @@ export const cloudCommand = defineCommand({
logger: (m: string) => out(m),
});

// iOS 16 deprecation notice (soft warning during the grace period;
// removed on 23 August 2026). Only fires on an explicit --ios-version 16 —
// when omitted the API defaults to iOS 17, so no false warning.
const DEPRECATED_IOS_VERSIONS = ['16'];
if (iOSVersion && DEPRECATED_IOS_VERSIONS.includes(iOSVersion)) {
warnOut(ui.warn(colors.bold('iOS 16 is deprecated')));
warnOut(
ui.branch([
'iOS 16 will be removed on 23 August 2026; after that, tests targeting it will fail.',
'Switch to iOS 17 or newer — iPhone 14 also supports 17 and 18.',
`${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/getting-started/devices-configuration')}`,
]),
);
}
// Render DB-driven notices (deprecation/warn/info/marketing) the API
// returned with the compatibility data. Replaces the previously hardcoded
// iOS-16 deprecation warning — that is now a seeded notice gated on the
// selected iOS version below. Honours --json via out/warnOut.
renderNotices(
compatibilityData.notices,
{
ios_version: iOSVersion,
android_api_level: androidApiLevel,
cli_version: cliVersion,
ci_provider: ciContext.provider,
ci_wrapper_version: ciContext.wrapperVersion,
},
{ out, warnOut },
);

deviceValidationService.validateAndroidDevice(
androidApiLevel,
Expand Down
137 changes: 137 additions & 0 deletions src/services/notices.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { ui } from '../utils/ui.js';
import { colors } from '../utils/styling.js';
import { compareSemver } from './version.service.js';

export type NoticeLevel = 'deprecation' | 'warn' | 'info' | 'marketing';

export interface NoticeMatchRule {
field: string;
op: 'present' | 'absent' | 'equals' | 'in' | 'not_in' | 'lt' | 'gt';
value?: unknown;
}

export interface NoticeMatch {
rules: NoticeMatchRule[];
}

/**
* The client-facing notice shape returned by the API (embedded in the
* compatibility response and from GET /notices). `match` is an optional display
* gate the CLI evaluates locally against its own context (e.g. the selected iOS
* version) — the API can't know those at fetch time.
*/
export interface Notice {
id: string;
slug: string | null;
level: NoticeLevel;
title: string;
body: string;
learnMoreUrl: string | null;
dismissible: boolean;
match: NoticeMatch | null;
}

/** Flat key/value context the `match` DSL is evaluated against. */
export type NoticeContext = Record<string, unknown>;

function isPresent(raw: unknown): boolean {
return raw !== null && raw !== undefined && raw !== false && raw !== '';
}

function ruleMatches(rule: NoticeMatchRule, ctx: NoticeContext): boolean {
const raw = ctx[rule.field];
switch (rule.op) {
case 'present':
return isPresent(raw);
case 'absent':
return !isPresent(raw);
case 'equals':
return isPresent(raw) && String(raw) === String(rule.value);
case 'in':
return (
isPresent(raw) &&
Array.isArray(rule.value) &&
rule.value.map(String).includes(String(raw))
);
case 'not_in':
return (
isPresent(raw) &&
Array.isArray(rule.value) &&
!rule.value.map(String).includes(String(raw))
);
case 'lt':
return (
isPresent(raw) &&
rule.value != null &&
compareSemver(String(raw), String(rule.value)) < 0
);
case 'gt':
return (
isPresent(raw) &&
rule.value != null &&
compareSemver(String(raw), String(rule.value)) > 0
);
default:
return false;
}
}

/** A null / empty match imposes no constraint; otherwise every rule must match (AND). */
export function matchesRules(
match: NoticeMatch | null | undefined,
ctx: NoticeContext,
): boolean {
if (!match || !Array.isArray(match.rules) || match.rules.length === 0) {
return true;
}
return match.rules.every((rule) => ruleMatches(rule, ctx));
}

export interface RenderNoticesOptions {
out: (message: string) => void;
warnOut: (message: string) => void;
}

/** Render a single notice with styling appropriate to its level. */
function renderNotice(notice: Notice, opts: RenderNoticesOptions): void {
const rows = [notice.body];
if (notice.learnMoreUrl) {
rows.push(`${colors.dim('See:')} ${colors.url(notice.learnMoreUrl)}`);
}

switch (notice.level) {
case 'deprecation':
case 'warn':
opts.warnOut(ui.warn(colors.bold(notice.title)));
opts.warnOut(ui.branch(rows));
break;
case 'marketing':
opts.out(ui.section(notice.title));
opts.out(ui.branch(rows));
break;
case 'info':
default:
opts.out(ui.info(colors.bold(notice.title)));
opts.out(ui.branch(rows));
break;
}
}

/**
* Filter notices by their local-context `match` gate and render those that pass.
* Returns the visible notices so a `--json` caller can include them in its
* payload instead of printing. When `--json` is active, callers pass no-op
* out/warnOut so nothing is printed but the list is still returned.
*/
export function renderNotices(
notices: Notice[] | undefined,
ctx: NoticeContext,
opts: RenderNoticesOptions,
): Notice[] {
if (!notices || notices.length === 0) return [];
const visible = notices.filter((n) => matchesRules(n.match, ctx));
for (const notice of visible) {
renderNotice(notice, opts);
}
return visible;
}
2 changes: 1 addition & 1 deletion src/services/version.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type LatestVersionResult =
* lexically (ASCII), and numeric always sorts below alphanumeric. A longer
* set of identifiers wins when all preceding ones are equal.
*/
function compareSemver(a: string, b: string): number {
export function compareSemver(a: string, b: string): number {
const split = (v: string): { release: number[]; pre: string[] } => {
const [core, ...preParts] = v.trim().replace(/^v/, '').split('-');
const nums = core.split('.').map((n) => Number(n) || 0);
Expand Down
36 changes: 36 additions & 0 deletions src/utils/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,39 @@ export function isCI(): boolean {

return !process.stdout.isTTY;
}

/** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */
export interface CiContext {
/** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */
provider: string | null;
/** The wrapper's own version, if it forwarded one (DCD_CI_WRAPPER_VERSION). */
wrapperVersion: string | null;
}

/** Infer the CI provider from the env vars each platform sets natively. */
function inferProvider(): string | null {
const env = process.env;
if (env.GITHUB_ACTIONS) return 'github';
if (env.BITRISE_IO || env.BITRISE_BUILD_NUMBER) return 'bitrise';
if (env.BITBUCKET_BUILD_NUMBER) return 'bitbucket';
if (env.EAS_BUILD || env.EAS_BUILD_RUNNER || env.EAS_BUILD_ID) return 'eas';
if (env.GITLAB_CI) return 'gitlab';
if (env.CIRCLECI) return 'circleci';
return null;
}

/**
* Resolve the CI integration context the CLI forwards to the notices API so
* notices can target a specific integration/version (e.g. "Bitbucket Pipe <
* 1.1.0"). The DCD CI wrappers set `DCD_CI_PROVIDER` / `DCD_CI_WRAPPER_VERSION`
* explicitly (preferred — carries the wrapper version); otherwise the provider
* is inferred from native env vars and the version is unknown.
*/
export function detectCiContext(): CiContext {
const forwardedProvider = process.env.DCD_CI_PROVIDER?.trim();
const forwardedVersion = process.env.DCD_CI_WRAPPER_VERSION?.trim();
return {
provider: forwardedProvider || inferProvider(),
wrapperVersion: forwardedVersion || null,
};
}
29 changes: 28 additions & 1 deletion src/utils/compatibility.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AuthContext } from '../types/domain/auth.types.js';
import type { Notice } from '../services/notices.service.js';

export interface CompatibilityData {
android: Record<string, string[]>;
Expand All @@ -9,20 +10,46 @@ export interface CompatibilityData {
latestVersion: string;
supportedVersions: string[];
};
/** Active CLI notices, piggybacked onto the compatibility response by the API. */
notices?: Notice[];
}

/** Identity the CLI forwards so the API can target notices by version / CI. */
export interface ClientContext {
cliVersion?: string;
ciProvider?: string | null;
ciWrapperVersion?: string | null;
}

let cachedCompatibilityData: CompatibilityData | null = null;

export async function fetchCompatibilityData(apiUrl: string, auth: AuthContext): Promise<CompatibilityData> {
export async function fetchCompatibilityData(
apiUrl: string,
auth: AuthContext,
clientContext?: ClientContext,
): Promise<CompatibilityData> {
if (cachedCompatibilityData) {
return cachedCompatibilityData;
}

// Forward CLI / CI identity so the API can version-filter and target notices.
const noticeHeaders: Record<string, string> = {};
if (clientContext?.cliVersion) {
noticeHeaders['x-dcd-cli-version'] = clientContext.cliVersion;
}
if (clientContext?.ciProvider) {
noticeHeaders['x-dcd-ci-provider'] = clientContext.ciProvider;
}
if (clientContext?.ciWrapperVersion) {
noticeHeaders['x-dcd-ci-wrapper-version'] = clientContext.ciWrapperVersion;
}

try {
const response = await fetch(`${apiUrl}/results/compatibility/data`, {
headers: {
'Content-Type': 'application/json',
...auth.headers,
...noticeHeaders,
},
method: 'GET',
});
Expand Down
18 changes: 6 additions & 12 deletions test/integration/upload.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,20 +188,14 @@
expect(stdout).to.include('skipping upload');
});

it('should attempt a real upload when the SHA check is bypassed', async () => {
it('should perform a real upload when the SHA check is bypassed', async () => {
const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ignore-sha-check --json`;

// Bypassing dedup makes the CLI upload to the storage URLs from the
// mock's example response, which point at real (unwritable) hosts —
// so this deterministically fails after attempting every upload path.
// It still verifies --ignore-sha-check skips the dedup short-circuit.
const { code, stdout } = await runExpectingFailure(command, {
timeout: 60_000,
});
expect(code).to.equal(1);
const result = JSON.parse(stdout);
expect(result).to.have.property('status', 'FAILED');
expect(result.error).to.include('All uploads failed');
// --ignore-sha-check skips the dedup short-circuit and performs a real
// upload. The mock returns a valid `uploads/` staging path, so the TUS
// fallback upload succeeds and the command returns the new binary id.
const { stdout } = await exec(command, { timeout: 60_000 });

Check warning

Code scanning / CodeQL

Shell command built from environment values Medium test

This shell command depends on an uncontrolled
absolute path
.
This shell command depends on an uncontrolled
absolute path
.
expectUploadJson(stdout);
});
});
});
Loading