@devicecloud.dev/dcd 5.3.0 → 5.3.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { getFlowsToRunInSequence, isFlowFile, processDependencies, readDirectory, readTestYamlFileAsJson, readYamlFileAsJson, } from './execution-plan.utils.js';
3
+ import { getFlowsToRunInSequence, isFlowFile, isWorkspaceConfigFile, processDependencies, readDirectory, readTestYamlFileAsJson, readYamlFileAsJson, } from './execution-plan.utils.js';
4
4
  /**
5
5
  * Recursively check and resolve all dependencies for a flow file
6
6
  * Includes runFlow references, JavaScript scripts, and media files
@@ -88,12 +88,14 @@ function extractDeviceCloudOverrides(config) {
88
88
  /**
89
89
  * Generate execution plan for a single flow file
90
90
  * @param normalizedInput - Normalized path to the flow file
91
- * @param configFile - Optional custom config file path
91
+ * @param resolvedConfigFile - Optional absolute path to a custom config file
92
92
  * @returns Execution plan for the single file with dependencies
93
93
  */
94
- async function planSingleFile(normalizedInput, configFile) {
95
- if (normalizedInput.endsWith('config.yaml') ||
96
- normalizedInput.endsWith('config.yml')) {
94
+ async function planSingleFile(normalizedInput, resolvedConfigFile) {
95
+ const inputBasename = path.basename(normalizedInput);
96
+ if (inputBasename === 'config.yaml' ||
97
+ inputBasename === 'config.yml' ||
98
+ isWorkspaceConfigFile(normalizedInput)) {
97
99
  throw new Error('If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config');
98
100
  }
99
101
  const { config } = readTestYamlFileAsJson(normalizedInput);
@@ -104,12 +106,11 @@ async function planSingleFile(normalizedInput, configFile) {
104
106
  flowOverrides[normalizedInput] = extractDeviceCloudOverrides(config);
105
107
  }
106
108
  let workspaceConfig;
107
- if (configFile) {
108
- const configFilePath = path.resolve(process.cwd(), configFile);
109
- if (!fs.existsSync(configFilePath)) {
110
- throw new Error(`Config file does not exist: ${configFilePath}`);
109
+ if (resolvedConfigFile) {
110
+ if (!fs.existsSync(resolvedConfigFile)) {
111
+ throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
111
112
  }
112
- workspaceConfig = readYamlFileAsJson(configFilePath);
113
+ workspaceConfig = readYamlFileAsJson(resolvedConfigFile);
113
114
  }
114
115
  const checkedDependancies = await checkDependencies(normalizedInput);
115
116
  return {
@@ -126,11 +127,24 @@ async function planSingleFile(normalizedInput, configFile) {
126
127
  * @param workspaceConfig - Workspace configuration containing flow globs
127
128
  * @param normalizedInput - Normalized path to the workspace directory
128
129
  * @param unfilteredFlowFiles - List of all discovered flow files
129
- * @param configFile - Optional custom config file path
130
+ * @param resolvedConfigFile - Optional absolute path to a custom config file
130
131
  * @param excludeFlows - --exclude-flows patterns to re-apply to glob matches
131
132
  * @returns Filtered list of flow file paths matching the globs
132
133
  */
133
- async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile, excludeFlows) {
134
+ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows) {
135
+ // Both branches compare absolute paths, so `--config ./x.yml` and
136
+ // `--config /abs/x.yml` behave identically, and a same-named file in a
137
+ // sibling directory is never mistaken for the active config.
138
+ const activeConfig = resolvedConfigFile
139
+ ? path.normalize(resolvedConfigFile)
140
+ : undefined;
141
+ const isExcludedConfig = (absolutePath) => {
142
+ const base = path.basename(absolutePath);
143
+ if (base === 'config.yaml' || base === 'config.yml')
144
+ return true;
145
+ return (activeConfig !== undefined &&
146
+ path.normalize(absolutePath) === activeConfig);
147
+ };
134
148
  if (workspaceConfig.flows) {
135
149
  const globs = workspaceConfig.flows.map((g) => g);
136
150
  // fs.globSync lands in Node 22; the CLI's `engines.node` already requires it.
@@ -144,29 +158,17 @@ async function applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFi
144
158
  return false;
145
159
  }
146
160
  });
161
+ // Resolve before filtering: glob matches are relative to normalizedInput,
162
+ // so comparing them against the config path only ever worked when the
163
+ // config happened to sit at the workspace root.
147
164
  const globbedFlowFiles = matchedFiles
148
- .filter((file) => {
149
- if (file === 'config.yaml' || file === 'config.yml')
150
- return false;
151
- if (configFile && file === path.basename(configFile))
152
- return false;
153
- if (!file.endsWith('.yaml') && !file.endsWith('.yml'))
154
- return false;
155
- const pathParts = file.split(path.sep);
156
- for (const part of pathParts) {
157
- if (part.endsWith('.app'))
158
- return false;
159
- }
160
- return true;
161
- })
162
- .map((file) => path.resolve(normalizedInput, file));
165
+ .map((file) => path.resolve(normalizedInput, file))
166
+ .filter((file) => !isExcludedConfig(file) && isFlowFile(file));
163
167
  // Re-globbing from disk bypasses the earlier --exclude-flows filter, so
164
168
  // re-apply it here or excluded flows sneak back in via `flows:` globs.
165
169
  return filterFlowFiles(globbedFlowFiles, excludeFlows);
166
170
  }
167
- return unfilteredFlowFiles.filter((file) => !file.endsWith('config.yaml') &&
168
- !file.endsWith('config.yml') &&
169
- (!configFile || !file.endsWith(configFile)));
171
+ return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file));
170
172
  }
171
173
  /**
172
174
  * Resolve sequential execution order from workspace config
@@ -226,11 +228,14 @@ export async function plan(options) {
226
228
  const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, } = options;
227
229
  const normalizedInput = path.normalize(input);
228
230
  const flowMetadata = {};
231
+ const resolvedConfigFile = configFile
232
+ ? path.resolve(process.cwd(), configFile)
233
+ : undefined;
229
234
  if (!fs.existsSync(normalizedInput)) {
230
235
  throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
231
236
  }
232
237
  if (fs.lstatSync(normalizedInput).isFile()) {
233
- return planSingleFile(normalizedInput, configFile);
238
+ return planSingleFile(normalizedInput, resolvedConfigFile);
234
239
  }
235
240
  let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
236
241
  if (unfilteredFlowFiles.length === 0) {
@@ -238,17 +243,30 @@ export async function plan(options) {
238
243
  }
239
244
  unfilteredFlowFiles = filterFlowFiles(unfilteredFlowFiles, excludeFlows);
240
245
  let workspaceConfig;
241
- if (configFile) {
242
- const configFilePath = path.resolve(process.cwd(), configFile);
243
- if (!fs.existsSync(configFilePath)) {
244
- throw new Error(`Config file does not exist: ${configFilePath}`);
246
+ if (resolvedConfigFile) {
247
+ if (!fs.existsSync(resolvedConfigFile)) {
248
+ throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
245
249
  }
246
- workspaceConfig = readYamlFileAsJson(configFilePath);
250
+ workspaceConfig = readYamlFileAsJson(resolvedConfigFile);
247
251
  }
248
252
  else {
249
253
  workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
250
254
  }
251
- unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, configFile, excludeFlows);
255
+ unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows);
256
+ // The exclusions above are filename-based: they only catch
257
+ // `config.yaml`/`config.yml` and the active --config target. Any other
258
+ // workspace config sharing the folder (a second CI workflow's, say) is still
259
+ // on the list and would be parsed as a flow, so drop config-shaped files by
260
+ // shape — see dcd-cli#99.
261
+ const configShapedFiles = new Set(unfilteredFlowFiles.filter((file) => isWorkspaceConfigFile(file)));
262
+ if (configShapedFiles.size > 0) {
263
+ if (debug) {
264
+ console.log(`[DEBUG] Skipping ${configShapedFiles.size} workspace config file(s): ${[
265
+ ...configShapedFiles,
266
+ ].join(', ')}`);
267
+ }
268
+ unfilteredFlowFiles = unfilteredFlowFiles.filter((file) => !configShapedFiles.has(file));
269
+ }
252
270
  if (unfilteredFlowFiles.length === 0) {
253
271
  const error = workspaceConfig.flows
254
272
  ? new Error(`Flow inclusion pattern(s) did not match any Flow files:\n${workspaceConfig.flows.join('\n')}`)
@@ -2,6 +2,21 @@ export declare function getFlowsToRunInSequence(paths: {
2
2
  [key: string]: string;
3
3
  }, flowOrder: string[], debug?: boolean): string[];
4
4
  export declare function isFlowFile(filePath: string): boolean;
5
+ /**
6
+ * True when a YAML file is a workspace config rather than a runnable flow.
7
+ *
8
+ * A flow is either `front matter --- steps` or a bare steps array; a
9
+ * single-document top-level map carrying workspace-config keys is neither, and
10
+ * left in the flow list it blows up processDependencies with "Expected an array
11
+ * of steps". Detection is by shape, not filename, so several named configs can
12
+ * coexist in one folder (dcd-cli#99). Requiring a recognised config key — not
13
+ * just "single document, top-level map" — keeps a flow that is merely *missing*
14
+ * its `---` separator loud rather than silently dropped.
15
+ *
16
+ * @param filePath - Path to the YAML file to classify
17
+ * @returns Whether the file is a workspace config rather than a flow
18
+ */
19
+ export declare function isWorkspaceConfigFile(filePath: string): boolean;
5
20
  export declare const readYamlFileAsJson: (filePath: string) => unknown;
6
21
  export declare const readTestYamlFileAsJson: (filePath: string) => {
7
22
  config: Record<string, unknown>;
@@ -38,6 +38,53 @@ export function isFlowFile(filePath) {
38
38
  }
39
39
  return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
40
40
  }
41
+ /**
42
+ * Top-level keys that only ever appear in a workspace config (see
43
+ * IWorkspaceConfig in execution-plan.service.ts). Deliberately excludes keys
44
+ * Maestro also allows in flow front matter — appId, name, tags, env,
45
+ * onFlowStart, onFlowComplete, jsEngine.
46
+ */
47
+ const WORKSPACE_CONFIG_KEYS = new Set([
48
+ 'excludeTags',
49
+ 'executionOrder',
50
+ 'flows',
51
+ 'includeTags',
52
+ 'local',
53
+ 'notifications',
54
+ 'platform',
55
+ ]);
56
+ /**
57
+ * True when a YAML file is a workspace config rather than a runnable flow.
58
+ *
59
+ * A flow is either `front matter --- steps` or a bare steps array; a
60
+ * single-document top-level map carrying workspace-config keys is neither, and
61
+ * left in the flow list it blows up processDependencies with "Expected an array
62
+ * of steps". Detection is by shape, not filename, so several named configs can
63
+ * coexist in one folder (dcd-cli#99). Requiring a recognised config key — not
64
+ * just "single document, top-level map" — keeps a flow that is merely *missing*
65
+ * its `---` separator loud rather than silently dropped.
66
+ *
67
+ * @param filePath - Path to the YAML file to classify
68
+ * @returns Whether the file is a workspace config rather than a flow
69
+ */
70
+ export function isWorkspaceConfigFile(filePath) {
71
+ let parsed;
72
+ try {
73
+ parsed = readTestYamlFileAsJson(filePath);
74
+ }
75
+ catch {
76
+ // Unparseable — leave it in the flow list so the existing error path reports it.
77
+ return false;
78
+ }
79
+ const { config, testSteps } = parsed;
80
+ if (config !== null)
81
+ return false; // has `---` front matter → flow
82
+ if (Array.isArray(testSteps))
83
+ return false; // bare steps array → flow
84
+ if (!testSteps || typeof testSteps !== 'object')
85
+ return false;
86
+ return Object.keys(testSteps).some((key) => WORKSPACE_CONFIG_KEYS.has(key));
87
+ }
41
88
  export const readYamlFileAsJson = (filePath) => {
42
89
  try {
43
90
  const normalizedPath = path.normalize(filePath);
package/package.json CHANGED
@@ -31,7 +31,7 @@
31
31
  "chai": "^6.2.2",
32
32
  "eslint": "^10.5.0",
33
33
  "eslint-config-prettier": "^10.1.8",
34
- "eslint-plugin-unicorn": "^72.0.0",
34
+ "eslint-plugin-unicorn": "^73.0.0",
35
35
  "husky": "^9.1.7",
36
36
  "mocha": "^11.7.6",
37
37
  "prettier": "^3.8.4",
@@ -59,7 +59,7 @@
59
59
  "type": "git",
60
60
  "url": "https://devicecloud.dev"
61
61
  },
62
- "version": "5.3.0",
62
+ "version": "5.3.1-beta.1",
63
63
  "bugs": {
64
64
  "url": "https://discord.gg/gm3mJwcNw8"
65
65
  },