@devicecloud.dev/dcd 5.3.1-beta.1 → 5.3.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.
package/README.md CHANGED
@@ -78,7 +78,8 @@ $ pnpm install # install deps, build, set up git hooks
78
78
  $ pnpm dcd <args> # run the CLI from source
79
79
  $ pnpm lint # ESLint
80
80
  $ pnpm typecheck # strict tsc, no emit
81
- $ pnpm test # build + boot mock API + integration/unit tests
81
+ $ pnpm test:unit # unit tests, no backend needed — what CI runs
82
+ $ pnpm test # the above, plus integration tests if MOCK_API_DIR is set
82
83
  ```
83
84
 
84
85
  ### Secret scanning
@@ -366,6 +366,9 @@ export const cloudCommand = defineCommand({
366
366
  excludeFlows,
367
367
  configFile,
368
368
  debug,
369
+ // Not warnOut: config problems are worth surfacing even under --json,
370
+ // and logger.warn writes to stderr so stdout stays parseable.
371
+ warn: (m) => logger.warn(m),
369
372
  });
370
373
  if (debug) {
371
374
  out(`[DEBUG] Execution plan generated`);
@@ -117,6 +117,8 @@ export function registerRunCloudTest(server) {
117
117
  excludeTags: args.excludeTags ?? [],
118
118
  excludeFlows: args.excludeFlows,
119
119
  configFile: args.configFile,
120
+ // stdout is the JSON-RPC channel — config warnings must go to stderr.
121
+ warn: logStderr,
120
122
  });
121
123
  const commonRoot = computeCommonRoot(executionPlan.flowsToRun, executionPlan.referencedFiles);
122
124
  const testMetadataMap = buildTestMetadataMap(executionPlan.flowMetadata, commonRoot);
@@ -1,37 +1,4 @@
1
- /** Email notification configuration */
2
- interface INotificationsConfig {
3
- email?: {
4
- enabled?: boolean;
5
- onSuccess?: boolean;
6
- recipients?: string[];
7
- };
8
- }
9
- /** Workspace configuration from config.yaml */
10
- interface IWorkspaceConfig {
11
- excludeTags?: null | string[];
12
- executionOrder?: IExecutionOrder | null;
13
- flows?: null | string[];
14
- includeTags?: null | string[];
15
- local?: ILocal | null;
16
- notifications?: INotificationsConfig;
17
- platform?: {
18
- android?: {
19
- disableAnimations?: boolean;
20
- };
21
- ios?: {
22
- disableAnimations?: boolean;
23
- };
24
- };
25
- }
26
- /** Local execution configuration */
27
- interface ILocal {
28
- deterministicOrder: boolean | null;
29
- }
30
- /** Sequential execution configuration */
31
- interface IExecutionOrder {
32
- continueOnFailure: boolean;
33
- flowsOrder: string[];
34
- }
1
+ import { IWorkspaceConfig } from './workspace-config.schema.js';
35
2
  /** Options for execution plan generation */
36
3
  export interface PlanOptions {
37
4
  configFile?: string;
@@ -40,6 +7,12 @@ export interface PlanOptions {
40
7
  excludeTags?: string[];
41
8
  includeTags?: string[];
42
9
  input: string;
10
+ /**
11
+ * Sink for non-fatal config problems. Injected rather than imported so the
12
+ * MCP server can route warnings to stderr — its stdout is the JSON-RPC
13
+ * channel.
14
+ */
15
+ warn?: (message: string) => void;
43
16
  }
44
17
  /** Execution plan containing all flows to run with metadata and dependencies */
45
18
  export interface IExecutionPlan {
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { getFlowsToRunInSequence, isFlowFile, isWorkspaceConfigFile, processDependencies, readDirectory, readTestYamlFileAsJson, readYamlFileAsJson, } from './execution-plan.utils.js';
3
+ import { getFlowsToRunInSequence, isFlowFile, isWorkspaceConfigFile, loadWorkspaceConfig, processDependencies, readDirectory, readTestYamlFileAsJson, } 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
@@ -54,15 +54,13 @@ function filterFlowFiles(unfilteredFlowFiles, excludeFlows) {
54
54
  * Load workspace configuration from config.yaml/yml if present
55
55
  * @param input - Input directory path
56
56
  * @param unfilteredFlowFiles - List of discovered flow files
57
+ * @param warn - Sink for non-fatal config problems
57
58
  * @returns Workspace configuration object (empty if no config file found)
58
59
  */
59
- function getWorkspaceConfig(input, unfilteredFlowFiles) {
60
+ function getWorkspaceConfig(input, unfilteredFlowFiles, warn) {
60
61
  const possibleConfigPaths = new Set([path.join(input, 'config.yaml'), path.join(input, 'config.yml')].map((p) => path.normalize(p)));
61
62
  const configFilePath = unfilteredFlowFiles.find((file) => possibleConfigPaths.has(path.normalize(file)));
62
- const config = configFilePath
63
- ? readYamlFileAsJson(configFilePath)
64
- : {};
65
- return config;
63
+ return configFilePath ? loadWorkspaceConfig(configFilePath, warn) : {};
66
64
  }
67
65
  /**
68
66
  * Extract DeviceCloud-specific override environment variables
@@ -88,10 +86,11 @@ function extractDeviceCloudOverrides(config) {
88
86
  /**
89
87
  * Generate execution plan for a single flow file
90
88
  * @param normalizedInput - Normalized path to the flow file
89
+ * @param warn - Sink for non-fatal config problems
91
90
  * @param resolvedConfigFile - Optional absolute path to a custom config file
92
91
  * @returns Execution plan for the single file with dependencies
93
92
  */
94
- async function planSingleFile(normalizedInput, resolvedConfigFile) {
93
+ async function planSingleFile(normalizedInput, warn, resolvedConfigFile) {
95
94
  const inputBasename = path.basename(normalizedInput);
96
95
  if (inputBasename === 'config.yaml' ||
97
96
  inputBasename === 'config.yml' ||
@@ -110,7 +109,14 @@ async function planSingleFile(normalizedInput, resolvedConfigFile) {
110
109
  if (!fs.existsSync(resolvedConfigFile)) {
111
110
  throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
112
111
  }
113
- workspaceConfig = readYamlFileAsJson(resolvedConfigFile);
112
+ workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn);
113
+ // Sequencing is resolved against a workspace's discovered flows, which a
114
+ // single-file input doesn't have — so executionOrder is ignored here. Say so
115
+ // rather than accepting a config that reads as if it applied (dcd-cli#110).
116
+ if (workspaceConfig.executionOrder?.flowsOrder.length) {
117
+ warn(`Warning: \`executionOrder\` in ${resolvedConfigFile} is ignored when a single flow file is passed.\n` +
118
+ `Pass the workspace folder instead so the named flows can be discovered and sequenced.`);
119
+ }
114
120
  }
115
121
  const checkedDependancies = await checkDependencies(normalizedInput);
116
122
  return {
@@ -225,7 +231,7 @@ function resolveSequentialFlows(workspaceConfig, pathsByName, debug) {
225
231
  * @throws Error if input path doesn't exist, no flows found, or dependencies missing
226
232
  */
227
233
  export async function plan(options) {
228
- const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, } = options;
234
+ const { input, includeTags = [], excludeTags = [], excludeFlows, configFile, debug = false, warn = (message) => console.warn(message), } = options;
229
235
  const normalizedInput = path.normalize(input);
230
236
  const flowMetadata = {};
231
237
  const resolvedConfigFile = configFile
@@ -235,7 +241,7 @@ export async function plan(options) {
235
241
  throw new Error(`Flow path does not exist: ${path.resolve(normalizedInput)}`);
236
242
  }
237
243
  if (fs.lstatSync(normalizedInput).isFile()) {
238
- return planSingleFile(normalizedInput, resolvedConfigFile);
244
+ return planSingleFile(normalizedInput, warn, resolvedConfigFile);
239
245
  }
240
246
  let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile);
241
247
  if (unfilteredFlowFiles.length === 0) {
@@ -247,10 +253,10 @@ export async function plan(options) {
247
253
  if (!fs.existsSync(resolvedConfigFile)) {
248
254
  throw new Error(`Config file does not exist: ${resolvedConfigFile}`);
249
255
  }
250
- workspaceConfig = readYamlFileAsJson(resolvedConfigFile);
256
+ workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn);
251
257
  }
252
258
  else {
253
- workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles);
259
+ workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles, warn);
254
260
  }
255
261
  unfilteredFlowFiles = await applyFlowGlobs(workspaceConfig, normalizedInput, unfilteredFlowFiles, resolvedConfigFile, excludeFlows);
256
262
  // The exclusions above are filename-based: they only catch
@@ -1,3 +1,4 @@
1
+ import { IWorkspaceConfig } from './workspace-config.schema.js';
1
2
  export declare function getFlowsToRunInSequence(paths: {
2
3
  [key: string]: string;
3
4
  }, flowOrder: string[], debug?: boolean): string[];
@@ -18,6 +19,20 @@ export declare function isFlowFile(filePath: string): boolean;
18
19
  */
19
20
  export declare function isWorkspaceConfigFile(filePath: string): boolean;
20
21
  export declare const readYamlFileAsJson: (filePath: string) => unknown;
22
+ /**
23
+ * Load and validate a workspace config file.
24
+ *
25
+ * The single chokepoint for reading a config: every caller gets a
26
+ * runtime-validated object instead of an unchecked `as IWorkspaceConfig` cast.
27
+ * Scalar-to-array coercion for `includeTags`/`excludeTags` lives in the schema,
28
+ * so `readYamlFileAsJson` stays a plain YAML read.
29
+ *
30
+ * @param filePath - Path to the config file
31
+ * @param warn - Sink for non-fatal problems (unrecognised keys)
32
+ * @returns The validated workspace config
33
+ * @throws Error if the file is unparseable or the config is invalid
34
+ */
35
+ export declare function loadWorkspaceConfig(filePath: string, warn: (message: string) => void): IWorkspaceConfig;
21
36
  export declare const readTestYamlFileAsJson: (filePath: string) => {
22
37
  config: Record<string, unknown>;
23
38
  testSteps: Record<string, unknown>[];
@@ -1,6 +1,7 @@
1
1
  import * as yaml from 'js-yaml';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { parseWorkspaceConfig, WORKSPACE_CONFIG_KEYS, } from './workspace-config.schema.js';
4
5
  const commandsThatRequireFiles = new Set(['addMedia', 'runFlow', 'runScript']);
5
6
  export function getFlowsToRunInSequence(paths, flowOrder, debug = false) {
6
7
  if (flowOrder.length === 0) {
@@ -38,21 +39,6 @@ export function isFlowFile(filePath) {
38
39
  }
39
40
  return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
40
41
  }
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
42
  /**
57
43
  * True when a YAML file is a workspace config rather than a runnable flow.
58
44
  *
@@ -89,17 +75,7 @@ export const readYamlFileAsJson = (filePath) => {
89
75
  try {
90
76
  const normalizedPath = path.normalize(filePath);
91
77
  const yamlText = fs.readFileSync(normalizedPath, 'utf8');
92
- const result = yaml.load(yamlText);
93
- // Ensure includeTags and excludeTags are always arrays if present
94
- if (result && typeof result === 'object') {
95
- if ('includeTags' in result && !Array.isArray(result.includeTags)) {
96
- result.includeTags = result.includeTags ? [result.includeTags] : [];
97
- }
98
- if ('excludeTags' in result && !Array.isArray(result.excludeTags)) {
99
- result.excludeTags = result.excludeTags ? [result.excludeTags] : [];
100
- }
101
- }
102
- return result;
78
+ return yaml.load(yamlText);
103
79
  }
104
80
  catch (error) {
105
81
  throw new Error(`Error parsing YAML file ${filePath}: ${error}`, {
@@ -107,6 +83,22 @@ export const readYamlFileAsJson = (filePath) => {
107
83
  });
108
84
  }
109
85
  };
86
+ /**
87
+ * Load and validate a workspace config file.
88
+ *
89
+ * The single chokepoint for reading a config: every caller gets a
90
+ * runtime-validated object instead of an unchecked `as IWorkspaceConfig` cast.
91
+ * Scalar-to-array coercion for `includeTags`/`excludeTags` lives in the schema,
92
+ * so `readYamlFileAsJson` stays a plain YAML read.
93
+ *
94
+ * @param filePath - Path to the config file
95
+ * @param warn - Sink for non-fatal problems (unrecognised keys)
96
+ * @returns The validated workspace config
97
+ * @throws Error if the file is unparseable or the config is invalid
98
+ */
99
+ export function loadWorkspaceConfig(filePath, warn) {
100
+ return parseWorkspaceConfig(readYamlFileAsJson(filePath), { filePath, warn });
101
+ }
110
102
  export const readTestYamlFileAsJson = (filePath) => {
111
103
  try {
112
104
  const normalizedPath = path.normalize(filePath);
@@ -0,0 +1,63 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * `looseObject`, not `object`: unknown keys are reported as warnings but must
4
+ * survive parsing, because the whole config is forwarded to the API as
5
+ * `fields.workspaceConfig` and stripping keys would silently alter that
6
+ * payload.
7
+ */
8
+ export declare const WorkspaceConfigSchema: z.ZodObject<{
9
+ excludeTags: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodArray<z.ZodString>>>>;
10
+ executionOrder: z.ZodOptional<z.ZodNullable<z.ZodObject<{
11
+ continueOnFailure: z.ZodDefault<z.ZodBoolean>;
12
+ flowsOrder: z.ZodArray<z.ZodString>;
13
+ }, z.core.$strip>>>;
14
+ flows: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
15
+ includeTags: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodArray<z.ZodString>>>>;
16
+ local: z.ZodOptional<z.ZodNullable<z.ZodObject<{
17
+ deterministicOrder: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
18
+ }, z.core.$loose>>>;
19
+ notifications: z.ZodOptional<z.ZodNullable<z.ZodObject<{
20
+ email: z.ZodOptional<z.ZodObject<{
21
+ enabled: z.ZodOptional<z.ZodBoolean>;
22
+ onSuccess: z.ZodOptional<z.ZodBoolean>;
23
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString>>;
24
+ }, z.core.$loose>>;
25
+ }, z.core.$loose>>>;
26
+ platform: z.ZodOptional<z.ZodNullable<z.ZodObject<{
27
+ android: z.ZodOptional<z.ZodObject<{
28
+ disableAnimations: z.ZodOptional<z.ZodBoolean>;
29
+ }, z.core.$loose>>;
30
+ ios: z.ZodOptional<z.ZodObject<{
31
+ disableAnimations: z.ZodOptional<z.ZodBoolean>;
32
+ }, z.core.$loose>>;
33
+ }, z.core.$loose>>>;
34
+ }, z.core.$loose>;
35
+ /** Workspace configuration from config.yaml */
36
+ export type IWorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
37
+ /**
38
+ * Top-level keys that only ever appear in a workspace config, derived from the
39
+ * schema so the two can't drift. Deliberately excludes keys Maestro also allows
40
+ * in flow front matter — appId, name, tags, env, onFlowStart, onFlowComplete,
41
+ * jsEngine — because this set also drives config-vs-flow detection
42
+ * (`isWorkspaceConfigFile`).
43
+ */
44
+ export declare const WORKSPACE_CONFIG_KEYS: ReadonlySet<string>;
45
+ /**
46
+ * Validate a parsed workspace config.
47
+ *
48
+ * Hard errors (thrown): the config isn't a map, or `executionOrder` is present
49
+ * but malformed. Both are unambiguous mistakes with no valid interpretation, so
50
+ * failing fast beats a warning that scrolls past in CI.
51
+ *
52
+ * Soft problems (warned): unknown top-level keys. These are preserved, not
53
+ * stripped, and warned about once.
54
+ *
55
+ * @param raw - The result of loading the YAML file
56
+ * @param options - Config file path (for messages) and a warning sink
57
+ * @returns The validated config, with defaults applied
58
+ * @throws Error if the config is not a map or `executionOrder` is malformed
59
+ */
60
+ export declare function parseWorkspaceConfig(raw: unknown, options: {
61
+ filePath: string;
62
+ warn: (message: string) => void;
63
+ }): IWorkspaceConfig;
@@ -0,0 +1,206 @@
1
+ import * as yaml from 'js-yaml';
2
+ import { z } from 'zod';
3
+ /**
4
+ * Runtime schema for a workspace `config.yaml`.
5
+ *
6
+ * This is the single source of truth for the config's shape — the TypeScript
7
+ * type is inferred from it (`IWorkspaceConfig`) rather than declared alongside
8
+ * it, so the compile-time and runtime views cannot drift. Before this existed
9
+ * the config was `yaml.load`ed and straight-cast, which meant an
10
+ * `executionOrder` written in the wrong shape was silently ignored and every
11
+ * flow ran in parallel (dcd-cli#110).
12
+ */
13
+ /**
14
+ * Tags may be written as a bare scalar (`includeTags: smoke`) or a list.
15
+ * Scalars are wrapped, and primitive members are coerced to strings so a
16
+ * YAML-numeric tag (`includeTags: [2]`) still compares against flow tags
17
+ * instead of silently matching nothing.
18
+ */
19
+ const tagList = z.preprocess((value) => {
20
+ if (value === null || value === undefined)
21
+ return value;
22
+ const members = Array.isArray(value) ? value : [value];
23
+ return members.map((member) => typeof member === 'number' || typeof member === 'boolean'
24
+ ? String(member)
25
+ : member);
26
+ }, z.array(z.string()));
27
+ /** Sequential execution configuration */
28
+ const ExecutionOrderSchema = z.object({
29
+ // Defaults to true: a failing flow does not stop the rest of the sequence.
30
+ // Declared here so the default lives in one place instead of being
31
+ // re-specified at each read site.
32
+ continueOnFailure: z.boolean().default(true),
33
+ flowsOrder: z.array(z.string()),
34
+ });
35
+ /**
36
+ * `looseObject`, not `object`: unknown keys are reported as warnings but must
37
+ * survive parsing, because the whole config is forwarded to the API as
38
+ * `fields.workspaceConfig` and stripping keys would silently alter that
39
+ * payload.
40
+ */
41
+ export const WorkspaceConfigSchema = z.looseObject({
42
+ excludeTags: tagList.nullish(),
43
+ executionOrder: ExecutionOrderSchema.nullish(),
44
+ flows: z.array(z.string()).nullish(),
45
+ includeTags: tagList.nullish(),
46
+ local: z
47
+ .looseObject({ deterministicOrder: z.boolean().nullish() })
48
+ .nullish(),
49
+ notifications: z
50
+ .looseObject({
51
+ email: z
52
+ .looseObject({
53
+ enabled: z.boolean().optional(),
54
+ onSuccess: z.boolean().optional(),
55
+ recipients: z.array(z.string()).optional(),
56
+ })
57
+ .optional(),
58
+ })
59
+ .nullish(),
60
+ platform: z
61
+ .looseObject({
62
+ android: z
63
+ .looseObject({ disableAnimations: z.boolean().optional() })
64
+ .optional(),
65
+ ios: z
66
+ .looseObject({ disableAnimations: z.boolean().optional() })
67
+ .optional(),
68
+ })
69
+ .nullish(),
70
+ });
71
+ /**
72
+ * Top-level keys that only ever appear in a workspace config, derived from the
73
+ * schema so the two can't drift. Deliberately excludes keys Maestro also allows
74
+ * in flow front matter — appId, name, tags, env, onFlowStart, onFlowComplete,
75
+ * jsEngine — because this set also drives config-vs-flow detection
76
+ * (`isWorkspaceConfigFile`).
77
+ */
78
+ export const WORKSPACE_CONFIG_KEYS = new Set(Object.keys(WorkspaceConfigSchema.shape));
79
+ /**
80
+ * Near-misses that aren't just a casing slip on a real key. Keyed lowercase.
81
+ */
82
+ const KEY_ALIASES = {
83
+ continueonfailure: 'executionOrder.continueOnFailure',
84
+ excludetag: 'excludeTags',
85
+ floworder: 'executionOrder.flowsOrder',
86
+ flowsorder: 'executionOrder.flowsOrder',
87
+ includetag: 'includeTags',
88
+ tags: 'includeTags / excludeTags',
89
+ };
90
+ /**
91
+ * Suggest the key the author probably meant.
92
+ * @param unknownKey - Unrecognised top-level key from the config
93
+ * @returns The suggested key name, or undefined if there's no close match
94
+ */
95
+ function suggestKey(unknownKey) {
96
+ const lowered = unknownKey.toLowerCase();
97
+ const casingSlip = [...WORKSPACE_CONFIG_KEYS].find((key) => key.toLowerCase() === lowered);
98
+ return casingSlip ?? KEY_ALIASES[lowered];
99
+ }
100
+ /**
101
+ * Describe how an `executionOrder` value is malformed, in prose.
102
+ *
103
+ * Split out so the most likely mistake — a bare list of flow names — gets a
104
+ * message showing the expected shape rather than a generic schema dump.
105
+ *
106
+ * @param value - The raw `executionOrder` value from the config
107
+ * @returns A description of the problem, or undefined if the shape is valid
108
+ */
109
+ function describeExecutionOrderShape(value) {
110
+ if (value === null || value === undefined)
111
+ return undefined;
112
+ if (Array.isArray(value))
113
+ return 'a bare list of flow names';
114
+ if (typeof value !== 'object')
115
+ return `a ${typeof value} value`;
116
+ const { flowsOrder } = value;
117
+ if (flowsOrder === undefined)
118
+ return 'a map with no `flowsOrder` key';
119
+ if (!Array.isArray(flowsOrder)) {
120
+ return 'a map whose `flowsOrder` is not a list';
121
+ }
122
+ return undefined;
123
+ }
124
+ /** Indent a YAML fragment so it reads as a block inside an error message. */
125
+ function indentYaml(value) {
126
+ return yaml
127
+ .dump(value, { lineWidth: -1 })
128
+ .trimEnd()
129
+ .split('\n')
130
+ .map((line) => ` ${line}`)
131
+ .join('\n');
132
+ }
133
+ /**
134
+ * Build the error for a malformed `executionOrder`, showing what was found
135
+ * next to what was expected.
136
+ * @param filePath - Path to the config file, for the message
137
+ * @param problem - Prose description from describeExecutionOrderShape
138
+ * @param found - The raw `executionOrder` value that failed
139
+ * @returns The error to throw
140
+ */
141
+ function executionOrderError(filePath, problem, found) {
142
+ return new Error(`Invalid \`executionOrder\` in ${filePath}\n\n` +
143
+ `\`executionOrder\` must be a map containing a \`flowsOrder\` list, but it is ${problem}.\n\n` +
144
+ `Found:\n${indentYaml({ executionOrder: found })}\n\n` +
145
+ `Expected:\n${indentYaml({
146
+ executionOrder: {
147
+ continueOnFailure: true,
148
+ flowsOrder: ['first-flow', 'second-flow'],
149
+ },
150
+ })}\n\n` +
151
+ `Without \`flowsOrder\` the flows are not sequenced — they all run in parallel.`);
152
+ }
153
+ /**
154
+ * Validate a parsed workspace config.
155
+ *
156
+ * Hard errors (thrown): the config isn't a map, or `executionOrder` is present
157
+ * but malformed. Both are unambiguous mistakes with no valid interpretation, so
158
+ * failing fast beats a warning that scrolls past in CI.
159
+ *
160
+ * Soft problems (warned): unknown top-level keys. These are preserved, not
161
+ * stripped, and warned about once.
162
+ *
163
+ * @param raw - The result of loading the YAML file
164
+ * @param options - Config file path (for messages) and a warning sink
165
+ * @returns The validated config, with defaults applied
166
+ * @throws Error if the config is not a map or `executionOrder` is malformed
167
+ */
168
+ export function parseWorkspaceConfig(raw, options) {
169
+ const { filePath, warn } = options;
170
+ if (raw === null || raw === undefined)
171
+ return {};
172
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
173
+ throw new Error(`Invalid workspace config in ${filePath}\n\n` +
174
+ `Expected a map of configuration keys (${[...WORKSPACE_CONFIG_KEYS].join(', ')}), but found ${Array.isArray(raw) ? 'a list' : `a ${typeof raw} value`}.`);
175
+ }
176
+ const rawConfig = raw;
177
+ // Checked ahead of the schema so this specific mistake gets a targeted
178
+ // message; the schema would otherwise report "expected object, received array".
179
+ const executionOrderProblem = describeExecutionOrderShape(rawConfig.executionOrder);
180
+ if (executionOrderProblem) {
181
+ throw executionOrderError(filePath, executionOrderProblem, rawConfig.executionOrder);
182
+ }
183
+ const unknownKeys = Object.keys(rawConfig).filter((key) => !WORKSPACE_CONFIG_KEYS.has(key));
184
+ if (unknownKeys.length > 0) {
185
+ const lines = unknownKeys.map((key) => {
186
+ const suggestion = suggestKey(key);
187
+ return suggestion
188
+ ? ` ${key} — did you mean ${suggestion}?`
189
+ : ` ${key}`;
190
+ });
191
+ warn(`Warning: unrecognised key(s) in ${filePath} — these are ignored:\n` +
192
+ `${lines.join('\n')}\n\n` +
193
+ `Supported keys: ${[...WORKSPACE_CONFIG_KEYS].join(', ')}`);
194
+ }
195
+ const result = WorkspaceConfigSchema.safeParse(rawConfig);
196
+ if (!result.success) {
197
+ const issues = result.error.issues
198
+ .map((issue) => {
199
+ const location = issue.path.length > 0 ? issue.path.join('.') : '(root)';
200
+ return ` ${location}: ${issue.message}`;
201
+ })
202
+ .join('\n');
203
+ throw new Error(`Invalid workspace config in ${filePath}\n\n${issues}`);
204
+ }
205
+ return result.data;
206
+ }
package/package.json CHANGED
@@ -59,7 +59,7 @@
59
59
  "type": "git",
60
60
  "url": "https://devicecloud.dev"
61
61
  },
62
- "version": "5.3.1-beta.1",
62
+ "version": "5.3.1",
63
63
  "bugs": {
64
64
  "url": "https://discord.gg/gm3mJwcNw8"
65
65
  },
@@ -78,6 +78,7 @@
78
78
  "build:binaries": "node scripts/build-binaries.mjs",
79
79
  "lint": "eslint src test --ext .ts",
80
80
  "test": "node scripts/test-runner.mjs",
81
+ "test:unit": "node scripts/test-runner.mjs --unit",
81
82
  "typecheck": "tsc --noEmit -p tsconfig.test.json"
82
83
  }
83
84
  }