diff --git a/src/commands/device.ts b/src/commands/device.ts index c30fc5d..099099e 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -899,7 +899,13 @@ const runSub = defineCommand({ let body: Parameters[3]; if (dir) { ctx.progress(ui.running(`Uploading the flows in ${colors.highlight(dir)}…`)); - const bundle = await uploadFlowBundle({ apiUrl: ctx.apiUrl, auth: ctx.auth, dir, log: ctx.progress }); + const bundle = await uploadFlowBundle({ + apiUrl: ctx.apiUrl, + auth: ctx.auth, + dir, + entry, + log: ctx.progress, + }); ctx.progress(ui.success(`Uploaded ${bundle.files} files`)); body = { flowUploadId: bundle.id, diff --git a/src/mcp/bridge.ts b/src/mcp/bridge.ts index 636e475..d271feb 100644 --- a/src/mcp/bridge.ts +++ b/src/mcp/bridge.ts @@ -63,7 +63,7 @@ const LOCAL_INPUTS: Record void, diff --git a/src/utils/device-bundle.ts b/src/utils/device-bundle.ts index f652d4f..7cfc103 100644 --- a/src/utils/device-bundle.ts +++ b/src/utils/device-bundle.ts @@ -1,9 +1,16 @@ import { createHash } from 'node:crypto'; -import { readdirSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import * as path from 'node:path'; import { DeviceGateway } from '../gateways/device-gateway.js'; import { compressFilesFromRelativePath } from '../methods.js'; +import { resolveIncludedPaths } from '../services/execution-plan.service.js'; +import { + isWorkspaceConfigFile, + loadWorkspaceConfig, + processDependencies, + readTestYamlFileAsJson, +} from '../services/execution-plan.utils.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import type { DeviceFlowBundle } from '../types/domain/device-session.types.js'; import { detectCiContext } from './ci.js'; @@ -26,13 +33,75 @@ function skipped(relativePath: string): boolean { .some((part) => part === 'node_modules' || (part.startsWith('.') && part !== '.')); } -/** Every file under `dir`, relative to it, in a stable order. */ -export function bundleFiles(dir: string): string[] { +const YAML_FILE = /\.ya?ml$/i; + +/** Every file under `dir`, relative to it. */ +function allFiles(dir: string): string[] { return readdirSync(dir, { recursive: true, withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name))) - .filter((file) => !skipped(file)) - .sort(); + .filter((file) => !skipped(file)); +} + +/** + * The files under `dir` a bundle needs, relative to it, in a stable order: + * every YAML file (flows, subflows, config), the files the flows use + * (runScript, addMedia, assertScreenshot and the like), and config's + * includedPaths. Nothing else: an agent's samples folder sent 265 MB of .apk + * and .zip files with its flows. + * + * `entry` is the one flow that will run. Its files must all be inside `dir`. + * Without an entry every flow may run, so that goes for all of them. Another + * flow's missing file doesn't stop the entry running, so it is left out. + */ +export function bundleFiles(dir: string, entry?: string, warn: (message: string) => void = () => {}): string[] { + const root = path.resolve(dir); + const yamlFiles = allFiles(dir).filter((file) => YAML_FILE.test(file)); + const entryFile = entry === undefined ? undefined : path.normalize(entry); + if (entryFile !== undefined && !yamlFiles.includes(entryFile)) { + throw new CliError(`No flow ${entry} in ${dir}.`); + } + + const needed = new Set(yamlFiles); + const problems: string[] = []; + const add = (absolute: string, strict: boolean, from: string) => { + const relative = path.relative(root, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + if (strict) problems.push(`${from} uses ${absolute}, which is outside ${dir}. Pass a directory that holds both.`); + return; + } + + needed.add(relative); + }; + + for (const file of yamlFiles) { + const absolute = path.join(root, file); + const strict = entryFile === undefined || entryFile === file; + if (isWorkspaceConfigFile(absolute)) continue; + let deps: ReturnType; + try { + const { config, testSteps } = readTestYamlFileAsJson(absolute); + deps = processDependencies({ config, input: absolute, testSteps }); + } catch (error) { + // Not a flow (a data file, say), or one Maestro will report itself. + if (entryFile === file) throw new CliError((error as Error).message); + continue; + } + + if (strict) problems.push(...deps.allErrors); + for (const dep of deps.allFiles) { + if (existsSync(dep)) add(dep, strict, file); + } + } + + for (const name of ['config.yaml', 'config.yml']) { + if (!yamlFiles.includes(name)) continue; + const config = loadWorkspaceConfig(path.join(root, name), warn); + for (const file of resolveIncludedPaths(config, root, warn)) add(file, true, name); + } + + if (problems.length > 0) throw new CliError(problems.join('\n')); + return [...needed].sort(); } /** PUT the zip to one signed URL. The URL is the credential. */ @@ -61,11 +130,14 @@ export interface UploadFlowBundleOptions { apiUrl: string; auth: AuthContext; dir: string; + /** The one flow that will run, a path inside `dir`. Omitted, all of them. */ + entry?: string; log: (message: string) => void; } /** - * Zip a directory of flows and upload it as a flow bundle: ask the API for + * Zip the files a directory's flows need (bundleFiles) and upload them as a + * flow bundle: ask the API for * upload URLs, PUT the zip to both copies, then register it with its * SHA-256. Encrypted like `dcd cloud`'s flow zips when the org's default * (or DCD_ENCRYPT) says so. Entries are relative to `dir`, so a run's @@ -74,9 +146,9 @@ export interface UploadFlowBundleOptions { export async function uploadFlowBundle( opts: UploadFlowBundleOptions, ): Promise { - const { apiUrl, auth, dir, log } = opts; - const files = bundleFiles(dir); - if (!files.some((f) => /\.ya?ml$/i.test(f))) { + const { apiUrl, auth, dir, entry, log } = opts; + const files = bundleFiles(dir, entry, log); + if (!files.some((f) => YAML_FILE.test(f))) { throw new CliError(`No flow files (.yaml) in ${dir}.`); } diff --git a/test/unit/device-bundle.test.ts b/test/unit/device-bundle.test.ts index f15bb8d..f68d493 100644 --- a/test/unit/device-bundle.test.ts +++ b/test/unit/device-bundle.test.ts @@ -82,10 +82,75 @@ describe('dcd device run : flow bundles', () => { fs.rmSync(dir, { force: true, recursive: true }); }); - it('bundles every file but dot-folders and node_modules', () => { + it('bundles the YAML files, leaving out dot-folders and node_modules', () => { expect(bundleFiles(dir)).to.deep.equal(['config.yaml', path.join('flows', 'login.yaml')]); }); + describe('which files go in', () => { + const write = (file: string, text = '') => { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), text); + }; + + beforeEach(() => { + write( + 'flows/checkout.yaml', + 'appId: x\n---\n- runFlow: ../subflows/pay.yaml\n- runScript: ../scripts/seed.js\n', + ); + write('subflows/pay.yaml', 'appId: x\n---\n- addMedia:\n - ../media/card.png\n'); + write('scripts/seed.js', 'output.ok = true'); + write('media/card.png', 'png'); + write('media/unused.png', 'png'); + write('builds/app.apk', 'apk'); + write('builds/app.zip', 'zip'); + }); + + it("takes the flows' scripts and media, but not builds or files no flow uses", () => { + expect(bundleFiles(dir)).to.deep.equal( + [ + 'config.yaml', + 'flows/checkout.yaml', + 'flows/login.yaml', + 'media/card.png', + 'scripts/seed.js', + 'subflows/pay.yaml', + ].map((f) => path.normalize(f)), + ); + }); + + it("adds config's includedPaths", () => { + write('config.yaml', 'flows: [flows/*]\nincludedPaths: [media/unused.png]\n'); + expect(bundleFiles(dir)).to.include(path.normalize('media/unused.png')); + }); + + it("refuses an entry whose file is missing, naming it", () => { + fs.rmSync(path.join(dir, 'scripts', 'seed.js')); + expect(() => bundleFiles(dir, 'flows/checkout.yaml')).to.throw(/seed\.js/); + }); + + it("runs an entry even when another flow's file is missing", () => { + write('flows/broken.yaml', 'appId: x\n---\n- runScript: ../scripts/gone.js\n'); + expect(bundleFiles(dir, 'flows/login.yaml')).to.include(path.normalize('flows/login.yaml')); + // Without an entry every flow may run, so the missing file matters. + expect(() => bundleFiles(dir)).to.throw(/gone\.js/); + }); + + it('refuses an entry that is not in the directory', () => { + expect(() => bundleFiles(dir, 'flows/nope.yaml')).to.throw('No flow flows/nope.yaml'); + }); + + it('refuses a flow that uses a file outside the directory', () => { + const outside = path.join(path.dirname(dir), `${path.basename(dir)}-shared.js`); + fs.writeFileSync(outside, ''); + try { + write('flows/out.yaml', `appId: x\n---\n- runScript: ../../${path.basename(outside)}\n`); + expect(() => bundleFiles(dir)).to.throw(/outside/); + } finally { + fs.rmSync(outside, { force: true }); + } + }); + }); + it('uploads the zip to both copies and registers what landed', async () => { const bundle = await uploadFlowBundle({ apiUrl: API, auth, dir, log: () => {} }); expect(bundle.id).to.equal('b-1');