diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59779568a..95fcba88d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -40,3 +40,6 @@ packages/plugins/async-queue @yoannmoin # Output packages/plugins/output @yoannmoinet + +# Apps +packages/plugins/apps @yoannmoinet \ No newline at end of file diff --git a/.yarn/cache/immediate-npm-3.0.6-c27588a2d3-f9b3486477.zip b/.yarn/cache/immediate-npm-3.0.6-c27588a2d3-f9b3486477.zip new file mode 100644 index 000000000..d3f74981e Binary files /dev/null and b/.yarn/cache/immediate-npm-3.0.6-c27588a2d3-f9b3486477.zip differ diff --git a/.yarn/cache/jszip-npm-3.10.1-2862546cfb-bfbfbb9b0a.zip b/.yarn/cache/jszip-npm-3.10.1-2862546cfb-bfbfbb9b0a.zip new file mode 100644 index 000000000..cbc3b4a92 Binary files /dev/null and b/.yarn/cache/jszip-npm-3.10.1-2862546cfb-bfbfbb9b0a.zip differ diff --git a/.yarn/cache/lie-npm-3.3.0-35ddd11a4d-f335ce67fe.zip b/.yarn/cache/lie-npm-3.3.0-35ddd11a4d-f335ce67fe.zip new file mode 100644 index 000000000..441e4c90a Binary files /dev/null and b/.yarn/cache/lie-npm-3.3.0-35ddd11a4d-f335ce67fe.zip differ diff --git a/.yarn/cache/pako-npm-1.0.11-b8f1b69d3e-1ad07210e8.zip b/.yarn/cache/pako-npm-1.0.11-b8f1b69d3e-1ad07210e8.zip new file mode 100644 index 000000000..c2a311f25 Binary files /dev/null and b/.yarn/cache/pako-npm-1.0.11-b8f1b69d3e-1ad07210e8.zip differ diff --git a/.yarn/cache/setimmediate-npm-1.0.5-54587459b6-76e3f5d7f4.zip b/.yarn/cache/setimmediate-npm-1.0.5-54587459b6-76e3f5d7f4.zip new file mode 100644 index 000000000..2ba5855b3 Binary files /dev/null and b/.yarn/cache/setimmediate-npm-1.0.5-54587459b6-76e3f5d7f4.zip differ diff --git a/LICENSES-3rdparty.csv b/LICENSES-3rdparty.csv index b18e4906e..c4011d179 100644 --- a/LICENSES-3rdparty.csv +++ b/LICENSES-3rdparty.csv @@ -478,6 +478,7 @@ http-signature,npm,MIT,Joyent Inc (https://github.com/joyent/node-http-signatur human-signals,npm,Apache-2.0,ehmicky (https://git.io/JeluP) husky,npm,MIT,Typicode (https://github.com/typicode/husky#readme) ignore,npm,MIT,kael (https://www.npmjs.com/package/ignore) +immediate,npm,MIT,(https://www.npmjs.com/package/immediate) import-fresh,npm,MIT,Sindre Sorhus (https://sindresorhus.com) import-local,npm,MIT,Sindre Sorhus (https://sindresorhus.com) imurmurhash,npm,MIT,Jens Taylor (https://github.com/jensyt/imurmurhash-js) @@ -563,9 +564,11 @@ json-stream-stringify,npm,MIT,Faleij (https://github.com/faleij) json-stringify-safe,npm,ISC,Isaac Z. Schlueter (https://github.com/isaacs/json-stringify-safe) json5,npm,MIT,Aseem Kishore (http://json5.org/) jsprim,npm,MIT,(https://www.npmjs.com/package/jsprim) +jszip,npm,(MIT OR GPL-3.0-or-later),Stuart Knightley (https://www.npmjs.com/package/jszip) keyv,npm,MIT,Jared Wray (https://github.com/jaredwray/keyv) leven,npm,MIT,Sindre Sorhus (sindresorhus.com) levn,npm,MIT,George Zahariev (https://github.com/gkz/levn) +lie,npm,MIT,(https://www.npmjs.com/package/lie) lines-and-columns,npm,MIT,Brian Donovan (https://github.com/eventualbuddha/lines-and-columns#readme) lint-staged,npm,MIT,Andrey Okonetchnikov (https://www.npmjs.com/package/lint-staged) listr2,npm,MIT,Cenk Kilic (https://srcs.kilic.dev) @@ -636,6 +639,7 @@ p-timeout,npm,MIT,Sindre Sorhus (sindresorhus.com) p-try,npm,MIT,Sindre Sorhus (sindresorhus.com) package-json-from-dist,npm,BlueOak-1.0.0,Isaac Z. Schlueter (https://izs.me) pad,npm,BSD-3-Clause,David Worms (https://github.com/adaltas/node-pad) +pako,npm,(MIT AND Zlib),(https://github.com/nodeca/pako) parent-module,npm,MIT,Sindre Sorhus (sindresorhus.com) parse-json,npm,MIT,Sindre Sorhus (https://sindresorhus.com) path-exists,npm,MIT,Sindre Sorhus (sindresorhus.com) @@ -705,6 +709,7 @@ serialize-javascript,npm,BSD-3-Clause,Eric Ferraiuolo (https://github.com/yahoo/ set-blocking,npm,ISC,Ben Coe (https://github.com/yargs/set-blocking#readme) set-function-length,npm,MIT,Jordan Harband (https://github.com/ljharb/set-function-length#readme) set-function-name,npm,MIT,Jordan Harband (https://github.com/ljharb/set-function-name#readme) +setimmediate,npm,MIT,YuzuJS (https://www.npmjs.com/package/setimmediate) shebang-command,npm,MIT,Kevin MÃ¥rtensson (github.com/kevva) shebang-regex,npm,MIT,Sindre Sorhus (sindresorhus.com) side-channel,npm,MIT,Jordan Harband (https://github.com/ljharb/side-channel#readme) diff --git a/README.md b/README.md index 07c60a572..f18f1cbea 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Follow the specific documentation for each bundler: logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'none', metadata?: { name?: string; - };; + }; errorTracking?: { enable?: boolean; sourcemaps?: { diff --git a/packages/core/src/helpers/env.ts b/packages/core/src/helpers/env.ts index 1f4ef04a3..7811aba62 100644 --- a/packages/core/src/helpers/env.ts +++ b/packages/core/src/helpers/env.ts @@ -16,9 +16,17 @@ const yellow = chalk.bold.yellow; // - DATADOG_APP_KEY // - DD_SOURCEMAP_INTAKE_URL // - DATADOG_SOURCEMAP_INTAKE_URL +// - DD_APPS_INTAKE_URL +// - DATADOG_APPS_INTAKE_URL // - DD_SITE // - DATADOG_SITE -const OVERRIDE_VARIABLES = ['API_KEY', 'APP_KEY', 'SOURCEMAP_INTAKE_URL', 'SITE'] as const; +const OVERRIDE_VARIABLES = [ + 'API_KEY', + 'APP_KEY', + 'SOURCEMAP_INTAKE_URL', + 'APPS_INTAKE_URL', + 'SITE', +] as const; type ENV_KEY = (typeof OVERRIDE_VARIABLES)[number]; // Return the environment variable that would be prefixed with either DATADOG_ or DD_. diff --git a/packages/core/src/helpers/request.ts b/packages/core/src/helpers/request.ts index 84dfdaf21..f5498c9ca 100644 --- a/packages/core/src/helpers/request.ts +++ b/packages/core/src/helpers/request.ts @@ -3,10 +3,50 @@ // Copyright 2019-Present Datadog, Inc. import retry from 'async-retry'; +import { Readable } from 'stream'; import type { RequestInit } from 'undici-types'; +import type { Gzip } from 'zlib'; +import { createGzip } from 'zlib'; import type { RequestOpts } from '../types'; +export const getOriginHeaders = (opts: { bundler: string; plugin: string; version: string }) => { + return { + 'DD-EVP-ORIGIN': `${opts.bundler}-build-plugin_${opts.plugin}`, + 'DD-EVP-ORIGIN-VERSION': opts.version, + }; +}; + +export type GzipFormData = { + data: Gzip; + headers: Record; +}; + +export type FormBuilder = (form: FormData) => Promise | void; + +export const createGzipFormData = async ( + builder: FormBuilder, + defaultHeaders: Record = {}, +): Promise => { + const form = new FormData(); + await builder(form); + + const gz = createGzip(); + // Serialize FormData through Request to get a streaming body and auto-generated headers + // (boundary) that we can forward while piping through gzip. + const req = new Request('fake://url', { method: 'POST', body: form }); + const formStream = Readable.fromWeb(req.body!); + const data = formStream.pipe(gz); + + const headers = { + 'Content-Encoding': 'gzip', + ...defaultHeaders, + ...Object.fromEntries(req.headers.entries()), + }; + + return { data, headers }; +}; + export const ERROR_CODES_NO_RETRY = [400, 403, 413]; export const NB_RETRIES = 5; // Do a retriable fetch. diff --git a/packages/core/src/helpers/strings.ts b/packages/core/src/helpers/strings.ts index 1af2c4a2b..528769e87 100644 --- a/packages/core/src/helpers/strings.ts +++ b/packages/core/src/helpers/strings.ts @@ -2,6 +2,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import chalk from 'chalk'; + // Format a duration 0h 0m 0s 0ms export const formatDuration = (duration: number) => { const days = Math.floor(duration / 1000 / 60 / 60 / 24); @@ -60,5 +62,35 @@ export const filterSensitiveInfoFromRepositoryUrl = (repositoryUrl: string = '') } }; +const formatValue = (value: unknown) => { + if (value === undefined) { + return 'undefined'; + } + + if (value === null) { + return 'null'; + } + + if (Array.isArray(value)) { + return value.join(', '); + } + + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + + return value?.toString() ?? ''; +}; + +export const prettyObject = (obj: any) => { + return Object.entries(obj) + .map(([key, value]) => ` - ${key}: ${chalk.bold.green(formatValue(value))}`) + .join('\n'); +}; + let index = 0; export const getUniqueId = () => `${Date.now()}.${performance.now()}.${++index}`; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f3a3908b0..785b2a406 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -8,6 +8,8 @@ import type { TrackedFilesMatcher } from '@dd/internal-git-plugin/trackedFilesMatcher'; /* eslint-disable arca/import-ordering */ // #imports-injection-marker +import type { AppsOptions } from '@dd/apps-plugin/types'; +import type * as apps from '@dd/apps-plugin'; import type { ErrorTrackingOptions } from '@dd/error-tracking-plugin/types'; import type * as errorTracking from '@dd/error-tracking-plugin'; import type { MetricsOptions } from '@dd/metrics-plugin/types'; @@ -254,6 +256,7 @@ export interface BaseOptions { export interface Options extends BaseOptions { // Each product should have a unique entry. // #types-injection-marker + [apps.CONFIG_KEY]?: AppsOptions; [errorTracking.CONFIG_KEY]?: ErrorTrackingOptions; [metrics.CONFIG_KEY]?: MetricsOptions; [output.CONFIG_KEY]?: OutputOptions; diff --git a/packages/factory/package.json b/packages/factory/package.json index 411253317..49b8ca3d1 100644 --- a/packages/factory/package.json +++ b/packages/factory/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dd/apps-plugin": "workspace:*", "@dd/core": "workspace:*", "@dd/error-tracking-plugin": "workspace:*", "@dd/internal-analytics-plugin": "workspace:*", diff --git a/packages/factory/src/helpers/context.test.ts b/packages/factory/src/helpers/context.test.ts index d5d322aa5..b0f1566e3 100644 --- a/packages/factory/src/helpers/context.test.ts +++ b/packages/factory/src/helpers/context.test.ts @@ -4,7 +4,6 @@ import type { Options, GlobalContext } from '@dd/core/types'; import { BUNDLER_VERSIONS } from '@dd/tests/_jest/helpers/constants'; -import { cleanEnv } from '@dd/tests/_jest/helpers/env'; import { defaultPluginOptions } from '@dd/tests/_jest/helpers/mocks'; import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; @@ -13,7 +12,6 @@ describe('Factory Helpers', () => { const initialContexts: Record = {}; const buildRoots: Record = {}; let workingDir: string; - let restoreEnv: () => void; beforeAll(async () => { const pluginConfig: Options = { @@ -41,15 +39,10 @@ describe('Factory Helpers', () => { }, }; - restoreEnv = cleanEnv(); const result = await runBundlers(pluginConfig); workingDir = result.workingDir; }); - afterAll(() => { - restoreEnv(); - }); - describe('getContext', () => { describe.each(BUNDLERS)('[$name|$version]', ({ name, version }) => { test('Should have the right initial context.', () => { diff --git a/packages/factory/src/index.ts b/packages/factory/src/index.ts index 63432f9a0..723569ffb 100644 --- a/packages/factory/src/index.ts +++ b/packages/factory/src/index.ts @@ -35,6 +35,7 @@ import { wrapGetPlugins } from './helpers/wrapPlugins'; import { ALL_ENVS, HOST_NAME } from '@dd/core/constants'; import { notifyOnEnvOverrides } from '@dd/core/helpers/env'; // #imports-injection-marker +import * as apps from '@dd/apps-plugin'; import * as errorTracking from '@dd/error-tracking-plugin'; import * as metrics from '@dd/metrics-plugin'; import * as output from '@dd/output-plugin'; @@ -49,6 +50,7 @@ import { getInjectionPlugins } from '@dd/internal-injection-plugin'; import { getTrueEndPlugins } from '@dd/internal-true-end-plugin'; // #imports-injection-marker // #types-export-injection-marker +export type { types as AppsTypes } from '@dd/apps-plugin'; export type { types as ErrorTrackingTypes } from '@dd/error-tracking-plugin'; export type { types as MetricsTypes } from '@dd/metrics-plugin'; export type { types as OutputTypes } from '@dd/output-plugin'; @@ -159,6 +161,7 @@ export const buildPluginFactory = ({ // Add the customer facing plugins. pluginsToAdd.push( // #configs-injection-marker + ['apps', apps.getPlugins], ['error-tracking', errorTracking.getPlugins], ['metrics', metrics.getPlugins], ['output', output.getPlugins], diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md new file mode 100644 index 000000000..41ac8ade1 --- /dev/null +++ b/packages/plugins/apps/README.md @@ -0,0 +1,67 @@ +# Apps Plugin + +A plugin to upload assets to Datadog's storage + +> [!WARNING] +> The Apps plugin is in **alpha** and is likely to break in most setups. +> Use it only for experimentation; behavior and APIs may change without notice. + + + +## Table of content + + + + +- [Configuration](#configuration) +- [Assets Upload](#assets-upload) + - [apps.dryRun](#appsdryrun) + - [apps.enable](#appsenable) + - [apps.include](#appsinclude) + - [apps.identifier](#appsidentifier) + + +## Configuration + +```ts +apps?: { + dryRun?: boolean; + enable?: boolean; + include?: string[]; + identifier?: string; +} +``` + +## Assets Upload + +Upload built assets to Datadog storage as a compressed archive. + +> [!NOTE] +> You can override the domain used in the request with the `DATADOG_SITE` environment variable or the `auth.site` options (eg. `datadoghq.eu`). +> You can override the full intake URL by setting the `DATADOG_APPS_INTAKE_URL` environment variable (eg. `https://apps-intake.datadoghq.com/api/v1/apps`). + +### apps.dryRun + +> default: `false` + +Prepare the archive and log the upload summary without sending anything to Datadog. + +### apps.enable + +> default: `true` when an `apps` config block is present + +Enable or disable the plugin without removing its configuration. + +### apps.include + +> default: `[]` + +Additional glob patterns (relative to the project root) to include in the uploaded archive. The bundler output directory is always included. + +### apps.identifier + +> default: an internal computation between the `name` and `repository` fields in `package.json` or from the `git` plugin. + +Override the app's identifier used to identify the current app against the assets upload API. + +Can be useful to enforce a static identifier instead of relying on possibly changing information like app's name and repository's url. diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json new file mode 100644 index 000000000..49047a57d --- /dev/null +++ b/packages/plugins/apps/package.json @@ -0,0 +1,34 @@ +{ + "name": "@dd/apps-plugin", + "packageManager": "yarn@4.0.2", + "license": "MIT", + "private": true, + "author": "Datadog", + "description": "A plugin to upload assets to Datadog's storage", + "homepage": "https://github.com/DataDog/build-plugins/tree/main/packages/plugins/apps#readme", + "repository": { + "type": "git", + "url": "https://github.com/DataDog/build-plugins", + "directory": "packages/plugins/apps" + }, + "buildPlugin": { + "hideFromRootReadme": true + }, + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dd/core": "workspace:*", + "chalk": "2.3.1", + "glob": "11.0.0", + "jszip": "3.10.1", + "pretty-bytes": "5.6.0" + }, + "devDependencies": { + "typescript": "5.4.3" + } +} diff --git a/packages/plugins/apps/src/archive.ts b/packages/plugins/apps/src/archive.ts new file mode 100644 index 000000000..0e2cc774d --- /dev/null +++ b/packages/plugins/apps/src/archive.ts @@ -0,0 +1,55 @@ +// 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 fsp from 'fs/promises'; +import fs from 'fs'; +import JSZip from 'jszip'; +import os from 'os'; +import path from 'path'; + +import type { Asset } from './assets'; +import { ARCHIVE_FILENAME } from './constants'; + +export type Archive = { + archivePath: string; + size: number; + assets: Asset[]; +}; + +export const createArchive = async (assets: Asset[]): Promise => { + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-')); + const archivePath = path.join(tempDir, ARCHIVE_FILENAME); + + const zip = new JSZip(); + for (const asset of assets) { + zip.file(asset.relativePath, fs.createReadStream(asset.absolutePath), { + binary: true, + compression: 'DEFLATE', + compressionOptions: { level: 9 }, + }); + } + + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(archivePath); + const stream = zip.generateNodeStream({ + type: 'nodebuffer', + streamFiles: true, + compression: 'DEFLATE', + compressionOptions: { level: 9 }, + }); + stream.on('error', reject); + output.on('error', reject); + output.on('close', resolve); + stream.pipe(output); + }); + + // Compute the size for logging purpose. + const { size } = await fsp.stat(archivePath); + + return { + archivePath, + size, + assets, + }; +}; diff --git a/packages/plugins/apps/src/assets.test.ts b/packages/plugins/apps/src/assets.test.ts new file mode 100644 index 000000000..05d24b691 --- /dev/null +++ b/packages/plugins/apps/src/assets.test.ts @@ -0,0 +1,59 @@ +// 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 { collectAssets } from '@dd/apps-plugin/assets'; +import { glob } from 'glob'; + +jest.mock('glob', () => ({ + glob: jest.fn(), +})); + +const globMock = jest.mocked(glob); + +describe('Apps Plugin - collectAssets', () => { + test('Should resolve unique assets with relative paths', async () => { + globMock.mockResolvedValue([ + '/root/project/dist/app.js', + '/root/project/dist/app.css', + '/root/project/public/favicon.ico', + ]); + + const assets = await collectAssets(['dist/**/*', 'public/**/*'], '/root/project'); + + expect(globMock).toHaveBeenCalledTimes(2); + expect(globMock).toHaveBeenNthCalledWith(1, 'dist/**/*', { + absolute: true, + cwd: '/root/project', + nodir: true, + }); + expect(globMock).toHaveBeenNthCalledWith(2, 'public/**/*', { + absolute: true, + cwd: '/root/project', + nodir: true, + }); + + expect(assets).toEqual([ + { + absolutePath: '/root/project/dist/app.js', + relativePath: 'dist/app.js', + }, + { + absolutePath: '/root/project/dist/app.css', + relativePath: 'dist/app.css', + }, + { + absolutePath: '/root/project/public/favicon.ico', + relativePath: 'public/favicon.ico', + }, + ]); + }); + + test('Should return an empty list when nothing matches', async () => { + globMock.mockResolvedValue([]); + + const assets = await collectAssets(['dist/**/*'], '/root/project'); + + expect(assets).toEqual([]); + }); +}); diff --git a/packages/plugins/apps/src/assets.ts b/packages/plugins/apps/src/assets.ts new file mode 100644 index 000000000..fb5e09f5b --- /dev/null +++ b/packages/plugins/apps/src/assets.ts @@ -0,0 +1,31 @@ +// 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 { glob } from 'glob'; +import path from 'path'; + +export type Asset = { + absolutePath: string; + relativePath: string; +}; + +export const collectAssets = async (patterns: string[], cwd: string): Promise => { + const matches = ( + await Promise.all( + patterns.map((pattern) => { + return glob(pattern, { absolute: true, cwd, nodir: true }); + }), + ) + ).flat(); + + const assets: Asset[] = Array.from(new Set(matches)).map((match) => { + const relativePath = path.relative(cwd, match); + return { + absolutePath: match, + relativePath, + }; + }); + + return assets; +}; diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts new file mode 100644 index 000000000..59b016a04 --- /dev/null +++ b/packages/plugins/apps/src/constants.ts @@ -0,0 +1,12 @@ +// 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 type { PluginName } from '@dd/core/types'; + +export const CONFIG_KEY = 'apps' as const; +export const PLUGIN_NAME: PluginName = 'datadog-apps-plugin' as const; + +export const APPS_API_SUBDOMAIN = 'apps-intake'; +export const APPS_API_PATH = 'api/v1/apps'; +export const ARCHIVE_FILENAME = 'datadog-apps-assets.zip'; diff --git a/packages/plugins/apps/src/identifier.test.ts b/packages/plugins/apps/src/identifier.test.ts new file mode 100644 index 000000000..0fe10cbc6 --- /dev/null +++ b/packages/plugins/apps/src/identifier.test.ts @@ -0,0 +1,171 @@ +// 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 { + buildIdentifier, + getPackageJson, + getRepositoryUrlFromPkg, + resolveIdentifier, + resolveRepositoryUrl, +} from '@dd/apps-plugin/identifier'; +import { readFileSync } from '@dd/core/helpers/fs'; +import { getClosestPackageJson } from '@dd/core/helpers/paths'; +import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; + +jest.mock('@dd/core/helpers/paths', () => ({ + getClosestPackageJson: jest.fn(), +})); + +jest.mock('@dd/core/helpers/fs', () => ({ + readFileSync: jest.fn(), +})); + +const getClosestPackageJsonMock = jest.mocked(getClosestPackageJson); +const readFileSyncMock = jest.mocked(readFileSync); + +describe('Apps Plugin - identifier helpers', () => { + const logger = getMockLogger(); + + describe('getPackageJson', () => { + test('Should read and parse the closest package.json', () => { + getClosestPackageJsonMock.mockReturnValue('/root/project/package.json'); + readFileSyncMock.mockReturnValue('{ "name": "my-app" }'); + + expect(getPackageJson('/root/project')).toEqual({ name: 'my-app' }); + }); + + test('Should return undefined when no package.json is found', () => { + getClosestPackageJsonMock.mockReturnValue(undefined); + + expect(getPackageJson('/root/project')).toBeUndefined(); + expect(getClosestPackageJsonMock).toHaveBeenCalledWith('/root/project'); + expect(readFileSyncMock).not.toHaveBeenCalled(); + }); + + test('Should return undefined when package.json cannot be parsed', () => { + getClosestPackageJsonMock.mockReturnValue('/root/project/package.json'); + readFileSyncMock.mockImplementation(() => { + throw new Error('parse error'); + }); + + expect(getPackageJson('/root/project')).toBeUndefined(); + }); + }); + + describe('getRepositoryUrlFromPkg', () => { + test('Should handle repository as string', () => { + expect(getRepositoryUrlFromPkg({ repository: 'https://github.com/org/repo.git' })).toBe( + 'https://github.com/org/repo.git', + ); + }); + + test('Should handle repository as object', () => { + expect( + getRepositoryUrlFromPkg({ + repository: { type: 'git', url: 'https://github.com/org/repo.git' }, + }), + ).toBe('https://github.com/org/repo.git'); + }); + + test('Should return undefined when no repository is provided', () => { + expect(getRepositoryUrlFromPkg({})).toBeUndefined(); + }); + }); + + describe('resolveRepositoryUrl', () => { + test('Should prefer provided repository URL and sanitize it', () => { + const result = resolveRepositoryUrl('git@github.com:org/repo.git'); + expect(result).toBe('git@github.com:org/repo'); + }); + + test('Should fallback to repository in package.json', () => { + const result = resolveRepositoryUrl(undefined, { + repository: 'https://github.com/org/repo.git', + }); + expect(result).toBe('https://github.com/org/repo'); + }); + + test('Should return undefined when no repository can be resolved', () => { + const result = resolveRepositoryUrl(undefined, {}); + expect(result).toBeUndefined(); + }); + }); + + describe('buildIdentifier', () => { + test('Should combine repository and name when both exist', () => { + expect(buildIdentifier('https://github.com/org/repo', 'my-app')).toBe( + 'https://github.com/org/repo:my-app', + ); + }); + + test('Should fallback to repository or name alone', () => { + expect(buildIdentifier('https://github.com/org/repo', undefined)).toBe( + 'https://github.com/org/repo', + ); + expect(buildIdentifier(undefined, 'my-app')).toBe('my-app'); + expect(buildIdentifier(undefined, undefined)).toBeUndefined(); + }); + }); + + describe('resolveIdentifier', () => { + test('Should compute the identifier from git remote and package name', () => { + getClosestPackageJsonMock.mockReturnValue('/root/project/package.json'); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + name: 'my-app', + }), + ); + + const id = resolveIdentifier( + '/root/project', + logger, + 'git@github.com:datadog/my-app.git', + ); + + expect(id).toBe('git@github.com:datadog/my-app:my-app'); + expect(mockLogFn).not.toHaveBeenCalled(); + }); + + test('Should pick repository from package.json when remote is missing', () => { + getClosestPackageJsonMock.mockReturnValue('/root/project/package.json'); + readFileSyncMock.mockReturnValue( + JSON.stringify({ + name: 'app-name', + repository: { + type: 'git', + url: 'https://github.com/org/repo.git', + }, + }), + ); + + const id = resolveIdentifier('/root/project', logger); + expect(id).toBe('https://github.com/org/repo:app-name'); + expect(mockLogFn).not.toHaveBeenCalled(); + }); + + test('Should log errors when unable to compute an identifier', () => { + getClosestPackageJsonMock.mockReturnValue(undefined); + + const id = resolveIdentifier('/root/project', logger); + + expect(id).toBeUndefined(); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('No package.json found'), + 'warn', + ); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Unable to determine the app name'), + 'error', + ); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Unable to determine the git remote'), + 'error', + ); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Unable to compute the app identifier'), + 'error', + ); + }); + }); +}); diff --git a/packages/plugins/apps/src/identifier.ts b/packages/plugins/apps/src/identifier.ts new file mode 100644 index 000000000..a459d6b75 --- /dev/null +++ b/packages/plugins/apps/src/identifier.ts @@ -0,0 +1,105 @@ +// 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 { readFileSync } from '@dd/core/helpers/fs'; +import { getClosestPackageJson } from '@dd/core/helpers/paths'; +import { filterSensitiveInfoFromRepositoryUrl } from '@dd/core/helpers/strings'; +import type { Logger } from '@dd/core/types'; +import chalk from 'chalk'; + +const red = chalk.bold.red; +const yellow = chalk.bold.yellow; + +type PkgJson = { + name?: string; + repository?: + | string + | { + type: string; + url: string; + }; +}; + +export const getPackageJson = (buildRoot: string): PkgJson | undefined => { + const packageJsonPath = getClosestPackageJson(buildRoot); + if (!packageJsonPath) { + return undefined; + } + try { + const packageJson = readFileSync(packageJsonPath); + return JSON.parse(packageJson); + } catch (e) { + // Let the caller handle the warnings. + return undefined; + } +}; + +export const getRepositoryUrlFromPkg = (pkg?: PkgJson): string | undefined => { + if (!pkg || !pkg.repository) { + return undefined; + } + + if (typeof pkg.repository === 'string') { + return pkg.repository; + } + + if ('url' in pkg.repository) { + return pkg.repository.url; + } + + return undefined; +}; + +export const resolveRepositoryUrl = ( + inputRepositoryUrl?: string, + pkg?: PkgJson, +): string | undefined => { + const repositoryUrl = inputRepositoryUrl || getRepositoryUrlFromPkg(pkg); + if (!repositoryUrl) { + return undefined; + } + + const sanitizedUrl = filterSensitiveInfoFromRepositoryUrl(repositoryUrl.trim()); + if (!sanitizedUrl) { + return undefined; + } + + return sanitizedUrl.replace(/\.git$/, ''); +}; + +export const buildIdentifier = (repository?: string, name?: string): string | undefined => { + if (repository && name) { + return `${repository}:${name}`; + } + + return repository || name; +}; + +export const resolveIdentifier = ( + buildRoot: string, + log: Logger, + repositoryUrl?: string, +): string | undefined => { + const pkg = getPackageJson(buildRoot); + if (!pkg) { + log.warn(yellow('No package.json found to infer the app name.')); + } + + const name = pkg?.name?.trim(); + if (!name) { + log.error(red('Unable to determine the app name to compute the app identifier.')); + } + + const repository = resolveRepositoryUrl(repositoryUrl, pkg); + if (!repository) { + log.error(red('Unable to determine the git remote to compute the app identifier.')); + } + + const identifier = buildIdentifier(repository, name); + if (!identifier) { + log.error(red('Unable to compute the app identifier.')); + } + + return identifier; +}; diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts new file mode 100644 index 000000000..3efed14d1 --- /dev/null +++ b/packages/plugins/apps/src/index.test.ts @@ -0,0 +1,184 @@ +// 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 * as archive from '@dd/apps-plugin/archive'; +import * as assets from '@dd/apps-plugin/assets'; +import * as identifier from '@dd/apps-plugin/identifier'; +import * as uploader from '@dd/apps-plugin/upload'; +import { getPlugins } from '@dd/apps-plugin'; +import * as fsHelpers from '@dd/core/helpers/fs'; +import { + getGetPluginsArg, + getMockBundler, + getRepositoryDataMock, + mockLogFn, +} from '@dd/tests/_jest/helpers/mocks'; +import { BUNDLERS, runBundlers } from '@dd/tests/_jest/helpers/runBundlers'; +import nock from 'nock'; +import path from 'path'; + +import { APPS_API_PATH, APPS_API_SUBDOMAIN } from './constants'; + +describe('Apps Plugin - getPlugins', () => { + const buildRoot = '/project'; + const outDir = '/project/dist'; + const getArgs = () => + getGetPluginsArg( + { apps: {} }, + { + bundler: { ...getMockBundler({ name: 'vite' }), outDir }, + buildRoot, + git: getRepositoryDataMock({ remote: 'git@github.com:org/repo.git' }), + }, + ); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(() => { + nock.cleanAll(); + }); + + test('Should not initialize when disabled', () => { + expect(getPlugins(getGetPluginsArg())).toHaveLength(0); + expect(getPlugins(getGetPluginsArg({ apps: { enable: false } }))).toHaveLength(0); + }); + + test('Should initialize when enabled', () => { + expect(getPlugins(getArgs())).toHaveLength(1); + }); + + test('Should log an error when identifier cannot be resolved', async () => { + const collectSpy = jest.spyOn(assets, 'collectAssets').mockResolvedValue([]); + const uploadSpy = jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ + errors: [], + warnings: [], + }); + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue(undefined); + + const plugin = getPlugins(getArgs())[0]; + await expect(plugin.asyncTrueEnd?.()).rejects.toThrow('Missing apps identification'); + + expect(uploadSpy).not.toHaveBeenCalled(); + expect(collectSpy).not.toHaveBeenCalled(); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Missing apps identification'), + 'error', + ); + }); + + test('Should skip upload when no assets are found', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue('repo:app'); + jest.spyOn(assets, 'collectAssets').mockResolvedValue([]); + jest.spyOn(archive, 'createArchive').mockResolvedValue({ + archivePath: '', + assets: [], + size: 0, + }); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ + errors: [], + warnings: [], + }); + const rmSpy = jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined as any); + + const plugin = getPlugins( + getGetPluginsArg( + { apps: { include: ['public/**/*'] } }, + { bundler: { ...getMockBundler({ name: 'vite' }), outDir }, buildRoot }, + ), + )[0]; + + await plugin.asyncTrueEnd?.(); + + expect(assets.collectAssets).toHaveBeenCalledWith(['public/**/*', 'dist/**/*'], buildRoot); + expect(archive.createArchive).not.toHaveBeenCalled(); + expect(uploader.uploadArchive).not.toHaveBeenCalled(); + expect(rmSpy).not.toHaveBeenCalled(); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('No assets to upload'), + 'info', + ); + }); + + test('Should upload archive, log warnings and cleanup temp directory', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue('repo:app'); + const mockedAssets = [ + { absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' }, + ]; + jest.spyOn(assets, 'collectAssets').mockResolvedValue(mockedAssets); + jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined as any); + jest.spyOn(archive, 'createArchive').mockResolvedValue({ + archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip', + assets: mockedAssets, + size: 10, + }); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ + errors: [], + warnings: ['first warning'], + }); + + const plugin = getPlugins(getArgs())[0]; + await plugin.asyncTrueEnd?.(); + + expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], buildRoot); + expect(archive.createArchive).toHaveBeenCalledWith(mockedAssets); + expect(uploader.uploadArchive).toHaveBeenCalledWith( + expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), + { + apiKey: '123', + bundlerName: 'vite', + dryRun: false, + identifier: 'repo:app', + site: 'example.com', + version: 'FAKE_VERSION', + }, + expect.anything(), + ); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Warnings while uploading assets'), + 'warn', + ); + expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-123')); + }); + + test('Should surface upload errors', async () => { + jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue('repo:app'); + const mockedAssets = [ + { absolutePath: '/project/dist/app.js', relativePath: 'dist/app.js' }, + ]; + jest.spyOn(assets, 'collectAssets').mockResolvedValue(mockedAssets); + jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined as any); + jest.spyOn(archive, 'createArchive').mockResolvedValue({ + archivePath: '/tmp/dd-apps-456/datadog-apps-assets.zip', + assets: mockedAssets, + size: 20, + }); + jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({ + errors: [new Error('upload failed')], + warnings: [], + }); + + const plugin = getPlugins(getArgs())[0]; + await expect(plugin.asyncTrueEnd?.()).rejects.toThrow('upload failed'); + + expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('upload failed'), 'error'); + expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-456')); + }); + + test('Should upload assets across all bundlers', async () => { + const replyMock = jest.fn(); + const intakeHost = `https://${APPS_API_SUBDOMAIN}.example.com`; + const scope = nock(intakeHost) + .post(`/${APPS_API_PATH}`) + .times(BUNDLERS.length) + .reply(200, replyMock); + + const { errors } = await runBundlers({ apps: { identifier: 'app-id' } }); + + expect(errors).toHaveLength(0); + expect(replyMock).toHaveBeenCalledTimes(BUNDLERS.length); + expect(scope.isDone()).toBe(true); + }); +}); diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts new file mode 100644 index 000000000..3a6eaa279 --- /dev/null +++ b/packages/plugins/apps/src/index.ts @@ -0,0 +1,126 @@ +// 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 { rm } from '@dd/core/helpers/fs'; +import type { GetPlugins } from '@dd/core/types'; +import chalk from 'chalk'; +import path from 'path'; + +import { createArchive } from './archive'; +import { collectAssets } from './assets'; +import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import { resolveIdentifier } from './identifier'; +import type { AppsOptions } from './types'; +import { uploadArchive } from './upload'; +import { validateOptions } from './validate'; + +export { CONFIG_KEY, PLUGIN_NAME }; + +const yellow = chalk.yellow.bold; +const red = chalk.red.bold; + +export type types = { + // Add the types you'd like to expose here. + AppsOptions: AppsOptions; +}; + +export const getPlugins: GetPlugins = ({ options, context }) => { + const log = context.getLogger(PLUGIN_NAME); + let toThrow: Error | undefined; + const validatedOptions = validateOptions(options); + if (!validatedOptions.enable) { + return []; + } + + const handleUpload = async () => { + const handleTimer = log.time('handle assets'); + let archiveDir: string | undefined; + try { + const identifierTimer = log.time('resolve identifier'); + const identifier = + validatedOptions.identifier || + resolveIdentifier(context.buildRoot, log, context.git?.remote); + + if (!identifier) { + // This will be caught and pretty printed at the end. + throw new Error(`Missing apps identification. +Either: + - pass an 'options.apps.identifier' to your plugin's configuration. + - have a 'name' and a 'repository' in your 'package.json'. + - have a valid remote url on your git project. +`); + } + identifierTimer.end(); + + const relativeOutdir = path.relative(context.buildRoot, context.bundler.outDir); + const assetGlobs = [...validatedOptions.include, `${relativeOutdir}/**/*`]; + + const assets = await collectAssets(assetGlobs, context.buildRoot); + + if (!assets.length) { + log.info(`No assets to upload.`); + return; + } + + const archiveTimer = log.time('archive assets'); + const archive = await createArchive(assets); + archiveTimer.end(); + // Store variable for later disposal of directory. + archiveDir = path.dirname(archive.archivePath); + + const uploadTimer = log.time('upload assets'); + const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive( + archive, + { + apiKey: context.auth.apiKey, + bundlerName: context.bundler.name, + dryRun: validatedOptions.dryRun, + identifier, + site: context.auth.site, + version: context.version, + }, + log, + ); + uploadTimer.end(); + + if (uploadWarnings.length > 0) { + log.warn( + `${yellow('Warnings while uploading assets:')}\n - ${uploadWarnings.join('\n - ')}`, + ); + } + + if (uploadErrors.length > 0) { + const listOfErrors = uploadErrors + .map((error) => error.cause || error.stack || error.message || error) + .join('\n - '); + throw new Error(` - ${listOfErrors}`); + } + } catch (error: any) { + toThrow = error; + log.error(`${red('Failed to upload assets:')}\n${error?.message || error}`); + } + + // Clean temporary directory + if (archiveDir) { + await rm(archiveDir); + } + handleTimer.end(); + + if (toThrow) { + // Break the build. + throw toThrow; + } + }; + + return [ + { + name: PLUGIN_NAME, + enforce: 'post', + async asyncTrueEnd() { + // Upload all the assets at the end of the build. + await handleUpload(); + }, + }, + ]; +}; diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts new file mode 100644 index 000000000..01b9d4f2d --- /dev/null +++ b/packages/plugins/apps/src/types.ts @@ -0,0 +1,15 @@ +// 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 type { WithRequired } from '@dd/core/types'; + +export type AppsOptions = { + enable?: boolean; + include?: string[]; + dryRun?: boolean; + identifier?: string; +}; + +// We don't enforce identifier, as it needs to be dynamically computed if absent. +export type AppsOptionsWithDefaults = WithRequired; diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts new file mode 100644 index 000000000..4c3e1ffd8 --- /dev/null +++ b/packages/plugins/apps/src/upload.test.ts @@ -0,0 +1,194 @@ +// 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 { getData, getIntakeUrl, uploadArchive } from '@dd/apps-plugin/upload'; +import { getDDEnvValue } from '@dd/core/helpers/env'; +import { getFile } from '@dd/core/helpers/fs'; +import { + createGzipFormData, + doRequest, + getOriginHeaders, + NB_RETRIES, +} from '@dd/core/helpers/request'; +import { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; +import stripAnsi from 'strip-ansi'; + +jest.mock('@dd/core/helpers/env', () => ({ + getDDEnvValue: jest.fn(), +})); + +jest.mock('@dd/core/helpers/fs', () => { + const actual = jest.requireActual('@dd/core/helpers/fs'); + return { + ...actual, + getFile: jest.fn(), + }; +}); + +jest.mock('@dd/core/helpers/request', () => { + const actual = jest.requireActual('@dd/core/helpers/request'); + return { + ...actual, + createGzipFormData: jest.fn(), + doRequest: jest.fn(), + getOriginHeaders: jest.fn(), + }; +}); + +const getDDEnvValueMock = jest.mocked(getDDEnvValue); +const createGzipFormDataMock = jest.mocked(createGzipFormData); +const getFileMock = jest.mocked(getFile); +const doRequestMock = jest.mocked(doRequest); +const getOriginHeadersMock = jest.mocked(getOriginHeaders); + +describe('Apps Plugin - upload', () => { + const archive = { + archivePath: '/tmp/datadog-apps-assets.zip', + assets: [{ absolutePath: '/tmp/a.js', relativePath: 'a.js' }], + size: 1234, + }; + const context = { + apiKey: 'api-key', + bundlerName: 'esbuild', + dryRun: false, + identifier: 'repo:app', + site: 'datadoghq.com', + version: '1.0.0', + }; + const logger = getMockLogger(); + + beforeEach(() => { + getOriginHeadersMock.mockReturnValue({ + 'DD-EVP-ORIGIN': 'origin', + 'DD-EVP-ORIGIN-VERSION': '0.0.0', + }); + }); + + describe('getIntakeUrl', () => { + test('Should use environment override when present', () => { + getDDEnvValueMock.mockReturnValue('https://custom.apps'); + expect(getIntakeUrl('datadoghq.com')).toBe('https://custom.apps'); + }); + + test('Should fallback to default intake url', () => { + getDDEnvValueMock.mockReturnValue(undefined); + expect(getIntakeUrl('datadoghq.eu')).toBe( + 'https://apps-intake.datadoghq.eu/api/v1/apps', + ); + }); + }); + + describe('getData', () => { + test('Should build form data with identifier and archive', async () => { + const appendMock = jest.fn(); + const fakeFile = { name: 'archive' }; + getFileMock.mockResolvedValue(fakeFile as any); + createGzipFormDataMock.mockImplementation(async (builder, defaultHeaders = {}) => { + await builder({ append: appendMock } as any); + return { data: 'data', headers: defaultHeaders } as any; + }); + + const getDataFn = getData('/tmp/archive.zip', { 'x-custom': '1' }, 'my-app'); + const data = await getDataFn(); + + expect(getFileMock).toHaveBeenCalledWith('/tmp/archive.zip', { + contentType: 'application/zip', + filename: 'datadog-apps-assets.zip', + }); + expect(appendMock).toHaveBeenCalledWith('identifier', 'my-app'); + expect(appendMock).toHaveBeenCalledWith('archive', fakeFile, 'datadog-apps-assets.zip'); + expect(data).toEqual({ data: 'data', headers: { 'x-custom': '1' } }); + }); + }); + + describe('uploadArchive', () => { + test('Should fail when missing apiKey', async () => { + const { errors, warnings } = await uploadArchive( + archive, + { ...context, apiKey: undefined }, + logger, + ); + expect(errors).toHaveLength(1); + expect(errors[0].message).toBe('No authentication token provided'); + expect(warnings).toHaveLength(0); + expect(doRequestMock).not.toHaveBeenCalled(); + }); + + test('Should fail when missing identifier', async () => { + const { errors, warnings } = await uploadArchive( + archive, + { ...context, identifier: '' }, + logger, + ); + expect(errors).toHaveLength(1); + expect(errors[0].message).toBe('No app identifier provided'); + expect(warnings).toHaveLength(0); + expect(doRequestMock).not.toHaveBeenCalled(); + }); + + test('Should log configuration and skip request on dryRun', async () => { + const { errors, warnings } = await uploadArchive( + archive, + { ...context, dryRun: true }, + logger, + ); + + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + expect(doRequestMock).not.toHaveBeenCalled(); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Dry run enabled'), + 'error', + ); + }); + + test('Should upload archive and log summary', async () => { + doRequestMock.mockResolvedValue(undefined as any); + + const { errors, warnings } = await uploadArchive(archive, context, logger); + + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + expect(getOriginHeadersMock).toHaveBeenCalledWith({ + bundler: 'esbuild', + plugin: 'apps', + version: '1.0.0', + }); + expect(doRequestMock).toHaveBeenCalledWith({ + auth: { apiKey: 'api-key' }, + url: 'https://apps-intake.datadoghq.com/api/v1/apps', + method: 'POST', + getData: expect.any(Function), + onRetry: expect.any(Function), + }); + expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('Uploaded'), 'info'); + }); + + test('Should collect warnings on retries', async () => { + doRequestMock.mockImplementation(async (opts) => { + opts.onRetry?.(new Error('network'), 2); + }); + + const { warnings } = await uploadArchive(archive, context, logger); + + expect(warnings).toHaveLength(1); + expect(stripAnsi(warnings[0])).toBe( + `Failed to upload archive (attempt 2/${NB_RETRIES}): network`, + ); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('Failed to upload archive'), + 'warn', + ); + }); + + test('Should return errors when upload fails', async () => { + doRequestMock.mockRejectedValue(new Error('boom')); + + const { errors } = await uploadArchive(archive, context, logger); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toBe('boom'); + }); + }); +}); diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts new file mode 100644 index 000000000..c22f07895 --- /dev/null +++ b/packages/plugins/apps/src/upload.ts @@ -0,0 +1,119 @@ +// 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 { getDDEnvValue } from '@dd/core/helpers/env'; +import { getFile } from '@dd/core/helpers/fs'; +import { + createGzipFormData, + doRequest, + getOriginHeaders, + NB_RETRIES, +} from '@dd/core/helpers/request'; +import { prettyObject } from '@dd/core/helpers/strings'; +import type { Logger } from '@dd/core/types'; +import chalk from 'chalk'; +import prettyBytes from 'pretty-bytes'; + +import type { Archive } from './archive'; +import { APPS_API_PATH, APPS_API_SUBDOMAIN, ARCHIVE_FILENAME } from './constants'; + +type DataResponse = Awaited>; + +export type UploadContext = { + apiKey?: string; + bundlerName: string; + dryRun: boolean; + identifier: string; + site: string; + version: string; +}; + +const green = chalk.green.bold; +const yellow = chalk.yellow.bold; +const cyan = chalk.cyan.bold; + +export const getIntakeUrl = (site: string) => { + const envIntake = getDDEnvValue('APPS_INTAKE_URL'); + return envIntake || `https://${APPS_API_SUBDOMAIN}.${site}/${APPS_API_PATH}`; +}; + +export const getData = + (archivePath: string, defaultHeaders: Record = {}, identifier: string) => + async (): Promise => { + const archiveFile = await getFile(archivePath, { + contentType: 'application/zip', + filename: ARCHIVE_FILENAME, + }); + + return createGzipFormData((form) => { + form.append('identifier', identifier); + form.append('archive', archiveFile, ARCHIVE_FILENAME); + }, defaultHeaders); + }; + +export const uploadArchive = async (archive: Archive, context: UploadContext, log: Logger) => { + const errors: Error[] = []; + const warnings: string[] = []; + + if (!context.apiKey) { + errors.push(new Error('No authentication token provided')); + return { errors, warnings }; + } + + if (!context.identifier) { + errors.push(new Error('No app identifier provided')); + return { errors, warnings }; + } + + const intakeUrl = getIntakeUrl(context.site); + const defaultHeaders = getOriginHeaders({ + bundler: context.bundlerName, + plugin: 'apps', + version: context.version, + }); + + const configurationString = prettyObject({ + identifier: context.identifier, + intakeUrl, + defaultHeaders: `\n${JSON.stringify(defaultHeaders, null, 2)}`, + }); + + const summary = `an archive of: + - ${green(archive.assets.length.toString())} files + - ${green(prettyBytes(archive.size))} + +With the configuration:\n${configurationString}`; + + if (context.dryRun) { + // Using log.error to ensure it's printed with high priority. + log.error( + `\n${cyan('Dry run enabled')}\n +Skipping assets upload. +Would have uploaded ${summary}`, + ); + return { errors, warnings }; + } + + try { + await doRequest({ + auth: { apiKey: context.apiKey }, + url: intakeUrl, + method: 'POST', + getData: getData(archive.archivePath, defaultHeaders, context.identifier), + onRetry: (error: Error, attempt: number) => { + const message = `Failed to upload archive (attempt ${yellow( + `${attempt}/${NB_RETRIES}`, + )}): ${error.message}`; + warnings.push(message); + log.warn(message); + }, + }); + log.info(`Uploaded ${summary}`); + } catch (error: unknown) { + const err = error instanceof Error ? error : new Error(String(error)); + errors.push(err); + } + + return { errors, warnings }; +}; diff --git a/packages/plugins/apps/src/validate.test.ts b/packages/plugins/apps/src/validate.test.ts new file mode 100644 index 000000000..b7f46cfbc --- /dev/null +++ b/packages/plugins/apps/src/validate.test.ts @@ -0,0 +1,69 @@ +// 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 { validateOptions } from '@dd/apps-plugin/validate'; + +describe('Apps Plugin - validateOptions', () => { + describe('enable flag', () => { + const cases = [ + { + description: 'return false when no apps config is provided', + input: {}, + expected: false, + }, + { + description: 'return true when apps config is an empty object', + input: { apps: {} }, + expected: true, + }, + { + description: 'respect explicit enable true', + input: { apps: { enable: true } }, + expected: true, + }, + { + description: 'respect explicit enable false', + input: { apps: { enable: false } }, + expected: false, + }, + ]; + + test.each(cases)('Should $description', ({ input, expected }) => { + const result = validateOptions(input); + expect(result.enable).toBe(expected); + }); + }); + + describe('defaults', () => { + test('Should set defaults when nothing is provided', () => { + const result = validateOptions({}); + expect(result).toEqual({ + dryRun: false, + enable: false, + include: [], + identifier: undefined, + }); + }); + }); + + describe('overrides', () => { + test('Should keep provided options and trim identifier', () => { + const result = validateOptions({ + apps: { + dryRun: true, + enable: true, + include: ['public/**/*', 'dist/**/*'], + identifier: ' my-app ', + }, + }); + + expect(result).toEqual({ + dryRun: true, + enable: true, + include: ['public/**/*', 'dist/**/*'], + identifier: 'my-app', + }); + }); + }); +}); diff --git a/packages/plugins/apps/src/validate.ts b/packages/plugins/apps/src/validate.ts new file mode 100644 index 000000000..9a93ea750 --- /dev/null +++ b/packages/plugins/apps/src/validate.ts @@ -0,0 +1,22 @@ +// 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 type { Options } from '@dd/core/types'; + +import { CONFIG_KEY } from './constants'; +import type { AppsOptions, AppsOptionsWithDefaults } from './types'; + +export const validateOptions = (options: Options): AppsOptionsWithDefaults => { + const resolvedOptions = (options[CONFIG_KEY] || {}) as AppsOptions; + const enable = resolvedOptions.enable ?? !!options[CONFIG_KEY]; + + const validatedOptions: AppsOptionsWithDefaults = { + enable, + include: resolvedOptions.include || [], + dryRun: resolvedOptions.dryRun ?? false, + identifier: resolvedOptions.identifier?.trim(), + }; + + return validatedOptions; +}; diff --git a/packages/plugins/apps/tsconfig.json b/packages/plugins/apps/tsconfig.json new file mode 100644 index 000000000..6c1d3065e --- /dev/null +++ b/packages/plugins/apps/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist" + }, + "include": ["**/*"], + "exclude": ["dist", "node_modules"] +} \ No newline at end of file diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts index 50774b892..1f6d5ab63 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/sender.test.ts @@ -32,9 +32,13 @@ jest.mock('@dd/core/helpers/fs', () => { }; }); -jest.mock('@dd/core/helpers/request', () => ({ - doRequest: jest.fn(), -})); +jest.mock('@dd/core/helpers/request', () => { + const original = jest.requireActual('@dd/core/helpers/request'); + return { + ...original, + doRequest: jest.fn(), + }; +}); const doRequestMock = jest.mocked(doRequest); diff --git a/packages/plugins/error-tracking/src/sourcemaps/sender.ts b/packages/plugins/error-tracking/src/sourcemaps/sender.ts index 253e768fc..877a38fdd 100644 --- a/packages/plugins/error-tracking/src/sourcemaps/sender.ts +++ b/packages/plugins/error-tracking/src/sourcemaps/sender.ts @@ -4,22 +4,18 @@ import { getDDEnvValue } from '@dd/core/helpers/env'; import { getFile } from '@dd/core/helpers/fs'; -import { doRequest, NB_RETRIES } from '@dd/core/helpers/request'; -import { formatDuration } from '@dd/core/helpers/strings'; +import { createGzipFormData, type GzipFormData } from '@dd/core/helpers/request'; +import { doRequest, getOriginHeaders, NB_RETRIES } from '@dd/core/helpers/request'; +import { formatDuration, prettyObject } from '@dd/core/helpers/strings'; import type { Logger, RepositoryData } from '@dd/core/types'; import chalk from 'chalk'; import PQueue from 'p-queue'; -import { Readable } from 'stream'; -import type { Gzip } from 'zlib'; -import { createGzip } from 'zlib'; import type { SourcemapsOptionsWithDefaults, Sourcemap } from '../types'; import type { Metadata, MultipartFileValue, Payload } from './payload'; import { getPayload } from './payload'; -type DataResponse = { data: Gzip; headers: Record }; - const green = chalk.green.bold; const yellow = chalk.yellow.bold; const red = chalk.red.bold; @@ -40,32 +36,18 @@ export const getIntakeUrl = (site: string) => { // Use a function to get new streams for each retry. export const getData = (payload: Payload, defaultHeaders: Record = {}) => - async (): Promise => { - const form = new FormData(); - const gz = createGzip(); - - for (const [key, content] of payload.content) { - const value = - content.type === 'file' - ? // eslint-disable-next-line no-await-in-loop - await getFile(content.path, content.options) - : new Blob([content.value], { type: content.options.contentType }); - - form.append(key, value, content.options.filename); - } - - // GZip data, we use a Request to serialize the data and transform it into a stream. - const req = new Request('fake://url', { method: 'POST', body: form }); - const formStream = Readable.fromWeb(req.body!); - const data = formStream.pipe(gz); - - const headers = { - 'Content-Encoding': 'gzip', - ...defaultHeaders, - ...Object.fromEntries(req.headers.entries()), - }; - - return { data, headers }; + async (): Promise => { + return createGzipFormData(async (form) => { + for (const [key, content] of payload.content) { + const value = + content.type === 'file' + ? // eslint-disable-next-line no-await-in-loop + await getFile(content.path, content.options) + : new Blob([content.value], { type: content.options.contentType }); + + form.append(key, value, content.options.filename); + } + }, defaultHeaders); }; export type UploadContext = { @@ -100,20 +82,19 @@ export const upload = async ( const Queue = PQueue.default ? PQueue.default : PQueue; const queue = new Queue({ concurrency: options.maxConcurrency }); const intakeUrl = getIntakeUrl(context.site); - const defaultHeaders = { - 'DD-EVP-ORIGIN': `${context.bundlerName}-build-plugin_sourcemaps`, - 'DD-EVP-ORIGIN-VERSION': context.version, - }; + const defaultHeaders = getOriginHeaders({ + bundler: context.bundlerName, + plugin: 'sourcemaps', + version: context.version, + }); // Show a pretty summary of the configuration. - const configurationString = Object.entries({ + const configurationString = prettyObject({ ...options, intakeUrl, outDir: context.outDir, defaultHeaders: `\n${JSON.stringify(defaultHeaders, null, 2)}`, - }) - .map(([key, value]) => ` - ${key}: ${green(value.toString())}`) - .join('\n'); + }); const summary = `\nUploading ${green(payloads.length.toString())} sourcemaps with configuration:\n${configurationString}`; diff --git a/packages/plugins/git/src/index.ts b/packages/plugins/git/src/index.ts index fea2484c4..9833b114d 100644 --- a/packages/plugins/git/src/index.ts +++ b/packages/plugins/git/src/index.ts @@ -44,6 +44,7 @@ export const getGitPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { const gitDir = getClosest(buildRoot, '.git'); if (!gitDir) { log.warn('No .git directory found, skipping git plugin.'); + timeGit.end(); return; } diff --git a/packages/plugins/rum/package.json b/packages/plugins/rum/package.json index 770cf3493..c2f6b9f03 100644 --- a/packages/plugins/rum/package.json +++ b/packages/plugins/rum/package.json @@ -11,6 +11,9 @@ "url": "https://github.com/DataDog/build-plugins", "directory": "packages/plugins/rum" }, + "buildPlugin": { + "hideFromRootReadme": true + }, "toBuild": { "rum-browser-sdk": { "entry": "./src/built/rum-browser-sdk.ts" diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index abd928b6b..18d3e2f59 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -55,6 +55,7 @@ "chalk": "2.3.1", "glob": "11.0.0", "json-stream-stringify": "3.1.6", + "jszip": "3.10.1", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/esbuild-plugin/src/index.ts b/packages/published/esbuild-plugin/src/index.ts index 02510d3e4..1b55796d1 100644 --- a/packages/published/esbuild-plugin/src/index.ts +++ b/packages/published/esbuild-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, @@ -23,6 +24,7 @@ import pkg from '../package.json'; export type EsbuildPluginOptions = Options; export type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index 953b6de48..2ab394c87 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -58,6 +58,7 @@ "chalk": "2.3.1", "glob": "11.0.0", "json-stream-stringify": "3.1.6", + "jszip": "3.10.1", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/rollup-plugin/src/index.ts b/packages/published/rollup-plugin/src/index.ts index 3756be7e0..6ab4d7896 100644 --- a/packages/published/rollup-plugin/src/index.ts +++ b/packages/published/rollup-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, @@ -23,6 +24,7 @@ import pkg from '../package.json'; export type RollupPluginOptions = Options; export type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index b5c4b8751..25f235584 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -55,6 +55,7 @@ "chalk": "2.3.1", "glob": "11.0.0", "json-stream-stringify": "3.1.6", + "jszip": "3.10.1", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/rspack-plugin/src/index.ts b/packages/published/rspack-plugin/src/index.ts index e3320c0e2..60488eec7 100644 --- a/packages/published/rspack-plugin/src/index.ts +++ b/packages/published/rspack-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, @@ -23,6 +24,7 @@ import pkg from '../package.json'; export type RspackPluginOptions = Options; export type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index d5e7b8d09..7eb93ca95 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -55,6 +55,7 @@ "chalk": "2.3.1", "glob": "11.0.0", "json-stream-stringify": "3.1.6", + "jszip": "3.10.1", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/vite-plugin/src/index.ts b/packages/published/vite-plugin/src/index.ts index ff4b54dfe..6e1c6494c 100644 --- a/packages/published/vite-plugin/src/index.ts +++ b/packages/published/vite-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, @@ -23,6 +24,7 @@ import pkg from '../package.json'; export type VitePluginOptions = Options; export type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index f0fb5dd17..46d9da44c 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -55,6 +55,7 @@ "chalk": "2.3.1", "glob": "11.0.0", "json-stream-stringify": "3.1.6", + "jszip": "3.10.1", "outdent": "0.8.0", "p-queue": "6.6.2", "pretty-bytes": "5.6.0", diff --git a/packages/published/webpack-plugin/src/index.ts b/packages/published/webpack-plugin/src/index.ts index 97a2eb3e8..86c193904 100644 --- a/packages/published/webpack-plugin/src/index.ts +++ b/packages/published/webpack-plugin/src/index.ts @@ -9,6 +9,7 @@ import type { Options } from '@dd/core/types'; import type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, @@ -23,6 +24,7 @@ import pkg from '../package.json'; export type WebpackPluginOptions = Options; export type { // #types-export-injection-marker + AppsTypes, ErrorTrackingTypes, MetricsTypes, OutputTypes, diff --git a/packages/tests/src/_jest/setupAfterEnv.ts b/packages/tests/src/_jest/setupAfterEnv.ts index c3e909f9c..4666ca4bf 100644 --- a/packages/tests/src/_jest/setupAfterEnv.ts +++ b/packages/tests/src/_jest/setupAfterEnv.ts @@ -27,16 +27,21 @@ jest.mock('async-retry', () => { }); }); +let restoreEnv: () => void; beforeAll(() => { const nock = jest.requireActual('nock'); + const { cleanEnv } = jest.requireActual('./helpers/env.ts'); // Do not send any HTTP requests. nock.disableNetConnect(); + // Need to clean env to avoid the `DD_SITE` leak from dd-trace in the CI. + restoreEnv = cleanEnv(); }); afterAll(async () => { // Clean the workingDirs from runBundlers(); const { cleanupEverything } = jest.requireActual('./helpers/runBundlers.ts'); await cleanupEverything(); + restoreEnv(); }); // Have a less verbose, console.log output. diff --git a/packages/tools/src/commands/integrity/readme.ts b/packages/tools/src/commands/integrity/readme.ts index 0f5e67b51..e25ff829a 100644 --- a/packages/tools/src/commands/integrity/readme.ts +++ b/packages/tools/src/commands/integrity/readme.ts @@ -18,6 +18,7 @@ import { getBundlerPicture, getSupportedBundlers, green, + isHiddenFromRootReadme, isInternalPluginWorkspace, red, replaceInBetween, @@ -47,13 +48,6 @@ type BundlerMetadata = { usage: string; }; -const README_EXCEPTIONS = [ - // We decided to not publicly communicate about the rum-plugin yet. - // But we keep its sources in so it can be tested internally - // and evolve with the rest of the ecosystem. - '@dd/rum-plugin', -]; - const error = red('Error|README'); // Matches image tags individually with surrounding whitespaces. const IMG_RX = /[\s]*)\/>[\s]*/g; @@ -368,13 +362,13 @@ export const updateReadmes = async (plugins: Workspace[], bundlers: Workspace[]) logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'none', metadata?: { name?: string; - }; + } `, ]; const errors: string[] = []; for (const plugin of plugins) { - if (README_EXCEPTIONS.includes(plugin.name)) { + if (isHiddenFromRootReadme(plugin)) { continue; } diff --git a/packages/tools/src/helpers.ts b/packages/tools/src/helpers.ts index 1753d6fa6..fa6c7bcde 100644 --- a/packages/tools/src/helpers.ts +++ b/packages/tools/src/helpers.ts @@ -289,3 +289,9 @@ export const getBundlerPicture = (bundler: string) => { export const isInternalPluginWorkspace = (workspace: Workspace) => workspace.name.startsWith('@dd/internal-'); + +export const isHiddenFromRootReadme = (workspace: Workspace) => { + const packageJsonPath = path.resolve(ROOT, workspace.location, 'package.json'); + const packageJson = readJsonSync(packageJsonPath); + return Boolean(packageJson.buildPlugin?.hideFromRootReadme); +}; diff --git a/packages/tools/src/rollupConfig.test.ts b/packages/tools/src/rollupConfig.test.ts index 93fbddb2e..6fd7287c5 100644 --- a/packages/tools/src/rollupConfig.test.ts +++ b/packages/tools/src/rollupConfig.test.ts @@ -17,7 +17,7 @@ import { } from '@dd/error-tracking-plugin/sourcemaps/sender'; import { METRICS_API_PATH } from '@dd/metrics-plugin/common/sender'; import { BUNDLER_VERSIONS, KNOWN_ERRORS } from '@dd/tests/_jest/helpers/constants'; -import { cleanEnv, getOutDir, prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; +import { getOutDir, prepareWorkingDir } from '@dd/tests/_jest/helpers/env'; import { FAKE_SITE, easyProjectEntry, @@ -148,7 +148,6 @@ const getBuiltFiles = () => { describe('Bundling', () => { let bundlerVersions: Partial> = {}; let processErrors: string[] = []; - let restoreEnv: () => void; const pluginConfig = getFullPluginConfig({ logLevel: 'error', customPlugins: () => [ @@ -202,8 +201,6 @@ describe('Bundling', () => { } return actualStderrWrite(err, ...args); }); - - restoreEnv = cleanEnv(); }); afterEach(() => { @@ -214,7 +211,6 @@ describe('Bundling', () => { afterAll(async () => { nock.cleanAll(); - restoreEnv(); }); const nameSize = Math.max(...BUNDLERS.map((bundler) => bundler.name.length)) + 1; diff --git a/yarn.lock b/yarn.lock index 436c7ae56..03ba8bf57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1701,6 +1701,7 @@ __metadata: esbuild: "npm:0.25.8" glob: "npm:11.0.0" json-stream-stringify: "npm:3.1.6" + jszip: "npm:3.10.1" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1745,6 +1746,7 @@ __metadata: esbuild: "npm:0.25.8" glob: "npm:11.0.0" json-stream-stringify: "npm:3.1.6" + jszip: "npm:3.10.1" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1782,6 +1784,7 @@ __metadata: esbuild: "npm:0.25.8" glob: "npm:11.0.0" json-stream-stringify: "npm:3.1.6" + jszip: "npm:3.10.1" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1819,6 +1822,7 @@ __metadata: esbuild: "npm:0.25.8" glob: "npm:11.0.0" json-stream-stringify: "npm:3.1.6" + jszip: "npm:3.10.1" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1856,6 +1860,7 @@ __metadata: esbuild: "npm:0.25.8" glob: "npm:11.0.0" json-stream-stringify: "npm:3.1.6" + jszip: "npm:3.10.1" outdent: "npm:0.8.0" p-queue: "npm:6.6.2" pretty-bytes: "npm:5.6.0" @@ -1870,6 +1875,19 @@ __metadata: languageName: unknown linkType: soft +"@dd/apps-plugin@workspace:*, @dd/apps-plugin@workspace:packages/plugins/apps": + version: 0.0.0-use.local + resolution: "@dd/apps-plugin@workspace:packages/plugins/apps" + dependencies: + "@dd/core": "workspace:*" + chalk: "npm:2.3.1" + glob: "npm:11.0.0" + jszip: "npm:3.10.1" + pretty-bytes: "npm:5.6.0" + typescript: "npm:5.4.3" + languageName: unknown + linkType: soft + "@dd/assets@workspace:*, @dd/assets@workspace:packages/assets": version: 0.0.0-use.local resolution: "@dd/assets@workspace:packages/assets" @@ -1909,6 +1927,7 @@ __metadata: version: 0.0.0-use.local resolution: "@dd/factory@workspace:packages/factory" dependencies: + "@dd/apps-plugin": "workspace:*" "@dd/core": "workspace:*" "@dd/error-tracking-plugin": "workspace:*" "@dd/internal-analytics-plugin": "workspace:*" @@ -7246,6 +7265,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: 10/f9b3486477555997657f70318cc8d3416159f208bec4cca3ff3442fd266bc23f50f0c9bd8547e1371a6b5e82b821ec9a7044a4f7b944798b25aa3cc6d5e63e62 + languageName: node + linkType: hard + "import-fresh@npm:^3.1.0, import-fresh@npm:^3.2.1": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" @@ -8349,6 +8375,18 @@ __metadata: languageName: node linkType: hard +"jszip@npm:3.10.1": + version: 3.10.1 + resolution: "jszip@npm:3.10.1" + dependencies: + lie: "npm:~3.3.0" + pako: "npm:~1.0.2" + readable-stream: "npm:~2.3.6" + setimmediate: "npm:^1.0.5" + checksum: 10/bfbfbb9b0a27121330ac46ab9cdb3b4812433faa9ba4a54742c87ca441e31a6194ff70ae12acefa5fe25406c432290e68003900541d948a169b23d30c34dd984 + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -8375,6 +8413,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:~3.3.0": + version: 3.3.0 + resolution: "lie@npm:3.3.0" + dependencies: + immediate: "npm:~3.0.5" + checksum: 10/f335ce67fe221af496185d7ce39c8321304adb701e122942c495f4f72dcee8803f9315ee572f5f8e8b08b9e8d7195da91b9fad776e8864746ba8b5e910adf76e + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.1.6 resolution: "lines-and-columns@npm:1.1.6" @@ -9103,6 +9150,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:~1.0.2": + version: 1.0.11 + resolution: "pako@npm:1.0.11" + checksum: 10/1ad07210e894472685564c4d39a08717e84c2a68a70d3c1d9e657d32394ef1670e22972a433cbfe48976cb98b154ba06855dcd3fcfba77f60f1777634bec48c0 + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -9417,7 +9471,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^2.0.6": +"readable-stream@npm:^2.0.6, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -9910,6 +9964,13 @@ __metadata: languageName: node linkType: hard +"setimmediate@npm:^1.0.5": + version: 1.0.5 + resolution: "setimmediate@npm:1.0.5" + checksum: 10/76e3f5d7f4b581b6100ff819761f04a984fa3f3990e72a6554b57188ded53efce2d3d6c0932c10f810b7c59414f85e2ab3c11521877d1dea1ce0b56dc906f485 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0"