diff --git a/packages/plugins/apps/src/cli.ts b/packages/plugins/apps/src/cli.ts new file mode 100644 index 000000000..0b873b782 --- /dev/null +++ b/packages/plugins/apps/src/cli.ts @@ -0,0 +1,205 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { DEFAULT_SITE, SITES } from '@dd/core/constants'; +import { getOriginHeaders } from '@dd/core/helpers/request'; +import type { Logger } from '@dd/core/types'; +import chalk from 'chalk'; +import { spawnSync } from 'child_process'; +import { Readable } from 'stream'; + +import { getAuthenticatedRequest } from './auth'; +import { resolveIdentifier } from './identifier'; +import { getReleaseUrl } from './upload'; +import { readVersionCache } from './version-cache'; + +const green = chalk.green.bold; +const red = chalk.red.bold; +const yellow = chalk.yellow.bold; +const cyan = chalk.cyan.bold; + +// CLI-safe output: process.stdout/stderr avoid the no-console lint rule, +// which is appropriate for library code but not for a CLI entry point. +const print = (msg: string) => process.stdout.write(`${msg}\n`); +const printErr = (msg: string) => process.stderr.write(`${msg}\n`); + +const makeCliLogger = (): Logger => { + const logger: Logger = { + error: (msg: unknown) => printErr(red(String(msg))), + warn: (msg: unknown) => printErr(yellow(String(msg))), + info: (msg: unknown) => print(String(msg)), + debug: (_msg: unknown) => {}, + getLogger: () => logger, + time: () => { + throw new Error('time() is not supported in CLI logger'); + }, + }; + return logger; +}; + +const cliLogger = makeCliLogger(); + +const USAGE = `Usage: datadog-apps [options] + +Commands: + deploy [--no-publish] Build and upload the app. Publishes by default. + publish [--version ] Publish an uploaded version without rebuilding. +`; + +const runDeploy = (args: string[]) => { + let noPublish = false; + const viteArgs: string[] = []; + + for (const arg of args) { + if (arg === '--no-publish') { + noPublish = true; + } else { + viteArgs.push(arg); + } + } + + const env: Record = { + ...process.env, + DATADOG_APPS_UPLOAD_ASSETS: 'true', + }; + + if (noPublish) { + env.DD_APPS_PUBLISH = 'false'; + } + + const viteCmd = ['vite', 'build', ...viteArgs].join(' '); + print(cyan(`Running: ${viteCmd}`)); + + const result = spawnSync(viteCmd, { + shell: true, + stdio: 'inherit', + env, + }); + + if (result.error) { + printErr(red(`Failed to spawn vite build: ${result.error.message}`)); + process.exit(1); + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +}; + +const runPublish = async (args: string[]) => { + let versionId: string | undefined; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--version' && i + 1 < args.length) { + versionId = args[i + 1]; + i++; + } + } + + const apiKey = process.env.DATADOG_API_KEY || process.env.DD_API_KEY; + const appKey = process.env.DATADOG_APP_KEY || process.env.DD_APP_KEY; + const rawSite = process.env.DATADOG_SITE || process.env.DD_SITE; + const site = SITES.find((s) => s === rawSite) ?? DEFAULT_SITE; + + // Use apiKey auth when both keys are present, otherwise fall back to OAuth. + // This matches the same resolution logic as the vite plugin's validate.ts. + const method = apiKey && appKey ? 'apiKey' : 'oauth'; + const auth = { apiKey, appKey, site }; + + let doAuthenticatedRequest; + try { + doAuthenticatedRequest = getAuthenticatedRequest(method, auth, cliLogger); + } catch { + printErr(red('Missing authentication. Set DD_API_KEY and DD_APP_KEY, or use OAuth.')); + process.exit(1); + } + + let identifier: string | undefined; + + if (!versionId) { + const cache = readVersionCache(); + if (!cache) { + printErr( + red( + `No --version provided and no version cache found (.datadog-app-version.json).\n` + + `Run \`datadog-apps deploy\` first, or pass --version .`, + ), + ); + process.exit(1); + } + versionId = cache.version_id; + identifier = cache.identifier; + print(`Using cached version ${cyan(versionId)} for identifier ${cyan(identifier)}`); + } + + if (!identifier) { + identifier = + process.env.DD_APPS_IDENTIFIER || + process.env.DATADOG_APPS_IDENTIFIER || + resolveIdentifier(process.cwd(), cliLogger).identifier; + } + + if (!identifier) { + printErr( + red( + 'Could not determine app identifier. Set DD_APPS_IDENTIFIER or run from your app directory.', + ), + ); + process.exit(1); + } + + const releaseUrl = getReleaseUrl(site, identifier); + const defaultHeaders = getOriginHeaders({ + bundler: 'vite', + plugin: 'apps', + version: '0.0.0', + }); + + print(`Publishing version ${cyan(versionId)} to ${green(releaseUrl)}...`); + + try { + await doAuthenticatedRequest({ + url: releaseUrl, + method: 'PUT', + type: 'json', + getData: async () => ({ + data: Readable.from(JSON.stringify({ version_id: versionId })), + headers: { + 'Content-Type': 'application/json', + ...defaultHeaders, + }, + }), + }); + print(green(`Successfully published version ${versionId} to live.`)); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + printErr(red(`Failed to publish: ${msg}`)); + process.exit(1); + } +}; + +const main = async () => { + const [, , command, ...rest] = process.argv; + + if (!command || command === '--help' || command === '-h') { + print(USAGE); + process.exit(0); + } + + if (command === 'deploy') { + runDeploy(rest); + } else if (command === 'publish') { + await runPublish(rest); + } else { + printErr(red(`Unknown command: ${command}`)); + print(USAGE); + process.exit(1); + } +}; + +main().catch((error: unknown) => { + const msg = error instanceof Error ? error.message : String(error); + printErr(red(`Unexpected error: ${msg}`)); + process.exit(1); +}); diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts index 4074bbc5b..1d0891e1d 100644 --- a/packages/plugins/apps/src/upload.test.ts +++ b/packages/plugins/apps/src/upload.test.ts @@ -34,6 +34,12 @@ jest.mock('@dd/core/helpers/request', () => { }; }); +jest.mock('@dd/apps-plugin/version-cache', () => ({ + writeVersionCache: jest.fn(), + readVersionCache: jest.fn(), + getVersionCachePath: jest.fn(), +})); + const getDDEnvValueMock = jest.mocked(getDDEnvValue); const createRequestDataMock = jest.mocked(createRequestData); const getFileMock = jest.mocked(getFile); diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts index 4bfcbd460..7e0329418 100644 --- a/packages/plugins/apps/src/upload.ts +++ b/packages/plugins/apps/src/upload.ts @@ -14,6 +14,7 @@ import { Readable } from 'stream'; import type { Archive } from './archive'; import type { DoAuthenticatedRequest } from './auth'; import { APPS_API_PATH, ARCHIVE_FILENAME } from './constants'; +import { writeVersionCache } from './version-cache'; type DataResponse = Awaited>; @@ -121,6 +122,15 @@ Would have uploaded ${summary}`, log.debug(`Uploaded ${summary}\n`); + if (response.version_id) { + try { + writeVersionCache(context.identifier, response.version_id); + } catch (cacheError: unknown) { + const msg = cacheError instanceof Error ? cacheError.message : String(cacheError); + log.debug(`Failed to write version cache: ${msg}`); + } + } + if (response.app_builder_id) { const appBuilderUrl = `https://app.${context.site}/app-builder/apps/${response.app_builder_id}`; diff --git a/packages/plugins/apps/src/version-cache.ts b/packages/plugins/apps/src/version-cache.ts new file mode 100644 index 000000000..c31b970c4 --- /dev/null +++ b/packages/plugins/apps/src/version-cache.ts @@ -0,0 +1,42 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import fs from 'fs'; +import path from 'path'; + +type VersionCache = { identifier: string; version_id: string }; + +const CACHE_FILENAME = '.datadog-app-version.json'; + +export const getVersionCachePath = (cwd = process.cwd()) => path.join(cwd, CACHE_FILENAME); + +export const writeVersionCache = (identifier: string, version_id: string, cwd = process.cwd()) => { + const cachePath = getVersionCachePath(cwd); + const data: VersionCache = { identifier, version_id }; + fs.writeFileSync(cachePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +}; + +const isVersionCache = (value: unknown): value is VersionCache => { + if (typeof value !== 'object' || value === null) { + return false; + } + // 'in' narrowing gives us object & { identifier: unknown } etc., no cast needed. + return ( + 'identifier' in value && + 'version_id' in value && + typeof value.identifier === 'string' && + typeof value.version_id === 'string' + ); +}; + +export const readVersionCache = (cwd = process.cwd()): VersionCache | null => { + const cachePath = getVersionCachePath(cwd); + try { + const raw = fs.readFileSync(cachePath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + return isVersionCache(parsed) ? parsed : null; + } catch { + return null; + } +}; diff --git a/packages/published/apps-cli/package.json b/packages/published/apps-cli/package.json new file mode 100644 index 000000000..96caef3f8 --- /dev/null +++ b/packages/published/apps-cli/package.json @@ -0,0 +1,52 @@ +{ + "name": "@datadog/apps-cli", + "packageManager": "yarn@4.0.2", + "version": "0.0.1", + "license": "MIT", + "author": "Datadog", + "description": "Datadog Apps CLI — deploy and publish Datadog Apps", + "keywords": [ + "datadog", + "apps", + "cli" + ], + "homepage": "https://github.com/DataDog/build-plugins#readme", + "repository": { + "type": "git", + "url": "https://github.com/DataDog/build-plugins", + "directory": "packages/published/apps-cli" + }, + "bin": { + "datadog-apps": "./dist/cli.js" + }, + "publishConfig": { + "access": "public", + "bin": { + "datadog-apps": "./dist/cli.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "buildCmd": "rollup --config rollup.config.mjs", + "build": "yarn clean && yarn buildCmd", + "clean": "rm -rf dist", + "prepack": "yarn build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "async-retry": "1.3.3", + "chalk": "2.3.1", + "pretty-bytes": "5.6.0" + }, + "devDependencies": { + "@dd/apps-plugin": "workspace:*", + "@dd/core": "workspace:*", + "@dd/tools": "workspace:*", + "esbuild": "0.25.8", + "rollup": "4.45.1", + "rollup-plugin-esbuild": "6.1.1", + "typescript": "5.4.3" + } +} diff --git a/packages/published/apps-cli/rollup.config.mjs b/packages/published/apps-cli/rollup.config.mjs new file mode 100644 index 000000000..8168e87bd --- /dev/null +++ b/packages/published/apps-cli/rollup.config.mjs @@ -0,0 +1,25 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { bundle } from '@dd/tools/rollupConfig.mjs'; +import path from 'path'; +import esbuild from 'rollup-plugin-esbuild'; + +import packageJson from './package.json' with { type: 'json' }; + +const CWD = process.env.PROJECT_CWD || process.cwd(); + +export default bundle(packageJson, { + plugins: [esbuild()], + input: { + cli: path.join(CWD, 'packages/plugins/apps/src/cli.ts'), + }, + output: { + banner: '#!/usr/bin/env node', + dir: 'dist', + entryFileNames: '[name].js', + format: 'cjs', + sourcemap: false, + }, +}); diff --git a/packages/published/apps-cli/tsconfig.json b/packages/published/apps-cli/tsconfig.json new file mode 100644 index 000000000..32b367efa --- /dev/null +++ b/packages/published/apps-cli/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist" + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/yarn.lock b/yarn.lock index 0ff5c99e5..ac6722a13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1625,6 +1625,25 @@ __metadata: languageName: node linkType: hard +"@datadog/apps-cli@workspace:packages/published/apps-cli": + version: 0.0.0-use.local + resolution: "@datadog/apps-cli@workspace:packages/published/apps-cli" + dependencies: + "@dd/apps-plugin": "workspace:*" + "@dd/core": "workspace:*" + "@dd/tools": "workspace:*" + async-retry: "npm:1.3.3" + chalk: "npm:2.3.1" + esbuild: "npm:0.25.8" + pretty-bytes: "npm:5.6.0" + rollup: "npm:4.45.1" + rollup-plugin-esbuild: "npm:6.1.1" + typescript: "npm:5.4.3" + bin: + datadog-apps: ./dist/cli.js + languageName: unknown + linkType: soft + "@datadog/browser-core@npm:6.26.0": version: 6.26.0 resolution: "@datadog/browser-core@npm:6.26.0"