Skip to content
Merged
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
8 changes: 7 additions & 1 deletion src/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,13 @@ const runSub = defineCommand({
let body: Parameters<typeof DeviceGateway.run>[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,
Expand Down
9 changes: 7 additions & 2 deletions src/mcp/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const LOCAL_INPUTS: Record<string, { description: string; properties: Record<str
},
run: {
description:
' Or pass path: a flow file on this machine (sent as yaml), or a directory of flows (uploaded as a bundle; flow picks the entry file inside it).',
' Or pass path: a flow file on this machine (sent as yaml), or a directory of flows (flow picks the entry file inside it). Only the YAML files and the files the flows use (scripts, media) are uploaded, not builds or anything else in the directory.',
properties: {
path: {
type: 'string',
Expand Down Expand Up @@ -114,7 +114,12 @@ export async function resolveLocalInputs(
if (!info) throw new Error(`No such file or directory: ${flowPath}`);
if (info.isDirectory()) {
logStderr(`Uploading the flows in ${flowPath}…`);
const bundle = await uploadFlowBundle({ ...ctx, dir: flowPath as string, log: logStderr });
const bundle = await uploadFlowBundle({
...ctx,
dir: flowPath as string,
entry: typeof rest.flow === 'string' ? rest.flow : undefined,
log: logStderr,
});
return { ...rest, flow_upload_id: bundle.id };
}

Expand Down
2 changes: 1 addition & 1 deletion src/services/execution-plan.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ const INCLUDED_PATHS_BYTES_WARN_THRESHOLD = 50 * 1024 * 1024;
* @returns Absolute paths of every matched file, deduped and sorted
* @throws Error if a pattern escapes the workspace root
*/
function resolveIncludedPaths(
export function resolveIncludedPaths(
workspaceConfig: IWorkspaceConfig,
normalizedInput: string,
warn: (message: string) => void,
Expand Down
90 changes: 81 additions & 9 deletions src/utils/device-bundle.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<typeof processDependencies>;
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. */
Expand Down Expand Up @@ -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
Expand All @@ -74,9 +146,9 @@ export interface UploadFlowBundleOptions {
export async function uploadFlowBundle(
opts: UploadFlowBundleOptions,
): Promise<DeviceFlowBundle & { files: number }> {
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}.`);
}

Expand Down
67 changes: 66 additions & 1 deletion test/unit/device-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,75 @@ describe('dcd device run <dir>: 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');
Expand Down
Loading