From 7e36017c870a587e38ab1dc1ae07ad143954480c Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:56:41 +0100 Subject: [PATCH 1/2] feat: render DB-driven notices and forward CLI/CI identity The CLI now renders deprecation/warn/info/marketing notices the API returns with the compatibility data, honouring --json. Removes the hardcoded iOS-16 warning (now a seeded notice gated on the selected iOS version). - notices.service: Notice type, match-DSL evaluator, level-aware renderer - ci.ts: detectCiContext() resolves provider + wrapper version (DCD_CI_*) - compatibility.ts: carries notices; forwards x-dcd-cli-version + x-dcd-ci-* headers - version.service: export compareSemver for reuse --- src/commands/cloud.ts | 39 +++++---- src/services/notices.service.ts | 137 ++++++++++++++++++++++++++++++++ src/services/version.service.ts | 2 +- src/utils/ci.ts | 36 +++++++++ src/utils/compatibility.ts | 29 ++++++- 5 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 src/services/notices.service.ts diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 4b8f3d8..5f8a75c 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -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, @@ -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 { @@ -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'); } @@ -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, diff --git a/src/services/notices.service.ts b/src/services/notices.service.ts new file mode 100644 index 0000000..ea3c5f5 --- /dev/null +++ b/src/services/notices.service.ts @@ -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; + +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; +} diff --git a/src/services/version.service.ts b/src/services/version.service.ts index 1ed8239..2340377 100644 --- a/src/services/version.service.ts +++ b/src/services/version.service.ts @@ -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); diff --git a/src/utils/ci.ts b/src/utils/ci.ts index 196b2dc..c5a8153 100644 --- a/src/utils/ci.ts +++ b/src/utils/ci.ts @@ -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, + }; +} diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index a48324a..af1934d 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -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; @@ -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 { +export async function fetchCompatibilityData( + apiUrl: string, + auth: AuthContext, + clientContext?: ClientContext, +): Promise { if (cachedCompatibilityData) { return cachedCompatibilityData; } + // Forward CLI / CI identity so the API can version-filter and target notices. + const noticeHeaders: Record = {}; + 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', }); From 88764abdc936c10b215d31b83be9ede6c5ea5c7e Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:13:45 +0100 Subject: [PATCH 2/2] test(upload): expect success when --ignore-sha-check bypasses dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dcd swagger fix (getBinaryUploadUrl now returns a valid uploads/ staging path) makes the TUS fallback upload succeed against dev storage, so this test no longer fails — invert it to assert the command succeeds and returns a binary id. --- test/integration/upload.integration.test.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/test/integration/upload.integration.test.ts b/test/integration/upload.integration.test.ts index ceadc8a..b41dc8e 100644 --- a/test/integration/upload.integration.test.ts +++ b/test/integration/upload.integration.test.ts @@ -188,20 +188,14 @@ describe('Upload Command Integration Tests', () => { 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 }); + expectUploadJson(stdout); }); }); });