Skip to content
Draft
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
205 changes: 205 additions & 0 deletions packages/plugins/apps/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <command> [options]

Commands:
deploy [--no-publish] Build and upload the app. Publishes by default.
publish [--version <id>] 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<string, string | undefined> = {
...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 <id>.`,
),
);
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);
});
6 changes: 6 additions & 0 deletions packages/plugins/apps/src/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/apps/src/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof createRequestData>>;

Expand Down Expand Up @@ -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}`;

Expand Down
42 changes: 42 additions & 0 deletions packages/plugins/apps/src/version-cache.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
52 changes: 52 additions & 0 deletions packages/published/apps-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@datadog/apps-cli",
"packageManager": "[email protected]",
"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"
}
}
25 changes: 25 additions & 0 deletions packages/published/apps-cli/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -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,
},
});
10 changes: 10 additions & 0 deletions packages/published/apps-cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"rootDir": "./",
"outDir": "./dist"
},
"include": ["**/*"],
"exclude": ["dist", "node_modules"]
}
Loading
Loading