@lumenflow/cli 3.5.0 → 3.6.0
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/dist/config-get.js +61 -20
- package/dist/config-get.js.map +1 -1
- package/dist/config-set.js +469 -70
- package/dist/config-set.js.map +1 -1
- package/dist/init-templates.js +12 -0
- package/dist/init-templates.js.map +1 -1
- package/dist/release.js +193 -223
- package/dist/release.js.map +1 -1
- package/dist/workspace-init.js +35 -3
- package/dist/workspace-init.js.map +1 -1
- package/dist/wu-claim.js +3 -2
- package/dist/wu-claim.js.map +1 -1
- package/dist/wu-create.js +74 -10
- package/dist/wu-create.js.map +1 -1
- package/dist/wu-delete.js +3 -3
- package/dist/wu-delete.js.map +1 -1
- package/dist/wu-done-already-merged.js +154 -0
- package/dist/wu-done-already-merged.js.map +1 -0
- package/dist/wu-done-git-ops.js +288 -0
- package/dist/wu-done-git-ops.js.map +1 -0
- package/dist/wu-done-policies.js +266 -0
- package/dist/wu-done-policies.js.map +1 -0
- package/dist/wu-done.js +64 -645
- package/dist/wu-done.js.map +1 -1
- package/dist/wu-edit.js +2 -2
- package/dist/wu-edit.js.map +1 -1
- package/dist/wu-repair.js +26 -8
- package/dist/wu-repair.js.map +1 -1
- package/package.json +7 -7
- package/packs/software-delivery/manifest-schema.ts +2 -0
- package/packs/software-delivery/manifest.ts +1 -0
- package/packs/software-delivery/manifest.yaml +1 -0
package/dist/config-set.js
CHANGED
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
4
|
/**
|
|
5
5
|
* @file config-set.ts
|
|
6
|
-
* WU-
|
|
6
|
+
* WU-2185: Workspace-aware config:set CLI command
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Routes keys by prefix:
|
|
9
|
+
* - WRITABLE_ROOT_KEYS -> write at workspace root
|
|
10
|
+
* - Pack config_key -> write under pack config block (validated against pack schema)
|
|
11
|
+
* - MANAGED_ROOT_KEYS -> error with "use <command>" guidance
|
|
12
|
+
* - Unknown -> hard error with did-you-mean
|
|
10
13
|
*
|
|
11
|
-
*
|
|
14
|
+
* All keys must be fully qualified from workspace root.
|
|
15
|
+
* No implicit software_delivery prefixing.
|
|
12
16
|
*
|
|
13
17
|
* Usage:
|
|
14
18
|
* pnpm config:set --key software_delivery.methodology.testing --value test-after
|
|
15
19
|
* pnpm config:set --key software_delivery.gates.minCoverage --value 85
|
|
16
|
-
* pnpm config:set --key
|
|
20
|
+
* pnpm config:set --key control_plane.sync_interval --value 60
|
|
17
21
|
*/
|
|
18
22
|
import path from 'node:path';
|
|
19
23
|
import { createError, ErrorCodes } from '@lumenflow/core';
|
|
@@ -23,8 +27,11 @@ import { findProjectRoot, WORKSPACE_CONFIG_FILE_NAME, clearConfigCache, } from '
|
|
|
23
27
|
import { die } from '@lumenflow/core/error-handler';
|
|
24
28
|
import { FILE_SYSTEM } from '@lumenflow/core/wu-constants';
|
|
25
29
|
import { withMicroWorktree } from '@lumenflow/core/micro-worktree';
|
|
26
|
-
import { LumenFlowConfigSchema
|
|
30
|
+
import { LumenFlowConfigSchema } from '@lumenflow/core/config-schema';
|
|
31
|
+
import { WorkspaceControlPlaneConfigSchema } from '@lumenflow/kernel/schemas';
|
|
27
32
|
import { normalizeConfigKeys } from '@lumenflow/core/normalize-config-keys';
|
|
33
|
+
import { WRITABLE_ROOT_KEYS, MANAGED_ROOT_KEYS, WORKSPACE_ROOT_KEYS } from '@lumenflow/core/config';
|
|
34
|
+
import { resolvePackManifestPaths, resolvePackSchemaMetadata } from '@lumenflow/kernel/pack';
|
|
28
35
|
import { runCLI } from './cli-entry-point.js';
|
|
29
36
|
// ---------------------------------------------------------------------------
|
|
30
37
|
// Constants
|
|
@@ -37,35 +44,65 @@ const ARG_HELP = '--help';
|
|
|
37
44
|
const COMMIT_PREFIX = 'chore: config:set';
|
|
38
45
|
const WORKSPACE_INIT_COMMAND = 'pnpm workspace-init --yes';
|
|
39
46
|
export const WORKSPACE_FILE_NAME = WORKSPACE_CONFIG_FILE_NAME;
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Known sub-keys of LumenFlowConfigSchema (software_delivery pack config).
|
|
49
|
+
* Used for did-you-mean suggestions when a user provides an unqualified key.
|
|
50
|
+
*/
|
|
51
|
+
const KNOWN_SD_SUBKEYS = [
|
|
52
|
+
'version',
|
|
53
|
+
'methodology',
|
|
54
|
+
'gates',
|
|
55
|
+
'directories',
|
|
56
|
+
'state',
|
|
57
|
+
'git',
|
|
58
|
+
'wu',
|
|
59
|
+
'memory',
|
|
60
|
+
'ui',
|
|
61
|
+
'yaml',
|
|
62
|
+
'agents',
|
|
63
|
+
'experimental',
|
|
64
|
+
'cleanup',
|
|
65
|
+
'telemetry',
|
|
66
|
+
'cloud',
|
|
67
|
+
'lanes',
|
|
68
|
+
'escalation',
|
|
69
|
+
'package_manager',
|
|
70
|
+
'test_runner',
|
|
71
|
+
'build_command',
|
|
72
|
+
];
|
|
73
|
+
/** Well-known pack ID for the software-delivery pack */
|
|
74
|
+
const SD_PACK_ID = 'software-delivery';
|
|
42
75
|
// ---------------------------------------------------------------------------
|
|
43
76
|
// Help text
|
|
44
77
|
// ---------------------------------------------------------------------------
|
|
45
78
|
const SET_HELP_TEXT = `Usage: pnpm config:set --key <dotpath> --value <value>
|
|
46
79
|
|
|
47
80
|
Safely update ${WORKSPACE_FILE_NAME} via micro-worktree commit.
|
|
48
|
-
|
|
81
|
+
Keys must be fully qualified from the workspace root.
|
|
82
|
+
Validates against schema before writing.
|
|
49
83
|
|
|
50
84
|
Required:
|
|
51
|
-
${ARG_KEY} <dotpath> Config key in dot notation (
|
|
85
|
+
${ARG_KEY} <dotpath> Config key in dot notation (fully qualified)
|
|
52
86
|
${ARG_VALUE} <value> Value to set (comma-separated for arrays)
|
|
53
87
|
|
|
54
88
|
Examples:
|
|
55
89
|
pnpm config:set --key software_delivery.methodology.testing --value test-after
|
|
56
90
|
pnpm config:set --key software_delivery.gates.minCoverage --value 85
|
|
57
|
-
pnpm config:set --key
|
|
91
|
+
pnpm config:set --key control_plane.sync_interval --value 60
|
|
92
|
+
pnpm config:set --key memory_namespace --value my-project
|
|
58
93
|
`;
|
|
59
94
|
const GET_HELP_TEXT = `Usage: pnpm config:get --key <dotpath>
|
|
60
95
|
|
|
61
96
|
Read and display a value from ${WORKSPACE_FILE_NAME}.
|
|
97
|
+
Keys must be fully qualified from the workspace root.
|
|
62
98
|
|
|
63
99
|
Required:
|
|
64
|
-
${ARG_KEY} <dotpath> Config key in dot notation (
|
|
100
|
+
${ARG_KEY} <dotpath> Config key in dot notation (fully qualified)
|
|
65
101
|
|
|
66
102
|
Examples:
|
|
67
103
|
pnpm config:get --key software_delivery.methodology.testing
|
|
68
104
|
pnpm config:get --key software_delivery.gates.minCoverage
|
|
105
|
+
pnpm config:get --key control_plane.sync_interval
|
|
69
106
|
`;
|
|
70
107
|
// ---------------------------------------------------------------------------
|
|
71
108
|
// Argument parsing
|
|
@@ -120,24 +157,64 @@ export function parseConfigGetArgs(argv) {
|
|
|
120
157
|
}
|
|
121
158
|
return { key };
|
|
122
159
|
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Key routing (pure, no side effects)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
123
163
|
/**
|
|
124
|
-
*
|
|
164
|
+
* Route a fully-qualified config key to the correct write target.
|
|
125
165
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
166
|
+
* Routing rules (checked in order):
|
|
167
|
+
* 1. First segment in WRITABLE_ROOT_KEYS -> workspace-root
|
|
168
|
+
* 2. First segment matches a pack config_key -> pack-config
|
|
169
|
+
* 3. First segment in MANAGED_ROOT_KEYS -> managed-error
|
|
170
|
+
* 4. Otherwise -> unknown-error (with optional did-you-mean suggestion)
|
|
129
171
|
*
|
|
130
|
-
* @param key -
|
|
131
|
-
* @
|
|
172
|
+
* @param key - Fully qualified dotpath key (e.g., "software_delivery.gates.minCoverage")
|
|
173
|
+
* @param packConfigKeys - Map of config_key -> pack_id from loaded pack manifests
|
|
174
|
+
* @returns Route describing where/how to write the key
|
|
132
175
|
*/
|
|
133
|
-
export function
|
|
134
|
-
|
|
135
|
-
|
|
176
|
+
export function routeConfigKey(key, packConfigKeys) {
|
|
177
|
+
const segments = key.split('.');
|
|
178
|
+
const firstSegment = segments[0];
|
|
179
|
+
const subPath = segments.slice(1).join('.');
|
|
180
|
+
// 1. Writable root keys (e.g., control_plane, memory_namespace, event_namespace)
|
|
181
|
+
if (WRITABLE_ROOT_KEYS.has(firstSegment)) {
|
|
182
|
+
return { type: 'workspace-root', rootKey: firstSegment, subPath };
|
|
136
183
|
}
|
|
137
|
-
|
|
138
|
-
|
|
184
|
+
// 2. Pack config_key (e.g., software_delivery -> software-delivery pack)
|
|
185
|
+
const packId = packConfigKeys.get(firstSegment);
|
|
186
|
+
if (packId !== undefined) {
|
|
187
|
+
return { type: 'pack-config', rootKey: firstSegment, subPath, packId };
|
|
139
188
|
}
|
|
140
|
-
|
|
189
|
+
// 3. Managed root keys (e.g., packs, lanes, security, id, name, policies)
|
|
190
|
+
if (firstSegment in MANAGED_ROOT_KEYS) {
|
|
191
|
+
const managedCommand = MANAGED_ROOT_KEYS[firstSegment];
|
|
192
|
+
return { type: 'managed-error', rootKey: firstSegment, command: managedCommand };
|
|
193
|
+
}
|
|
194
|
+
// 4. Unknown key - check for did-you-mean suggestions
|
|
195
|
+
const suggestion = buildDidYouMeanSuggestion(key, firstSegment, packConfigKeys);
|
|
196
|
+
return { type: 'unknown-error', rootKey: firstSegment, suggestion };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build a did-you-mean suggestion for an unknown key.
|
|
200
|
+
*
|
|
201
|
+
* If the first segment matches a known sub-key of a pack config schema,
|
|
202
|
+
* suggest the fully-qualified version.
|
|
203
|
+
*
|
|
204
|
+
* @param fullKey - The full user-provided key
|
|
205
|
+
* @param firstSegment - The first segment of the key
|
|
206
|
+
* @param packConfigKeys - Map of config_key -> pack_id
|
|
207
|
+
* @returns A suggestion string, or undefined if no match
|
|
208
|
+
*/
|
|
209
|
+
function buildDidYouMeanSuggestion(fullKey, firstSegment, packConfigKeys) {
|
|
210
|
+
// Check if first segment is a known SD sub-key
|
|
211
|
+
if (KNOWN_SD_SUBKEYS.includes(firstSegment)) {
|
|
212
|
+
// Find the pack config_key that owns SD config
|
|
213
|
+
for (const [configKey] of packConfigKeys) {
|
|
214
|
+
return `Did you mean "${configKey}.${fullKey}"?`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
141
218
|
}
|
|
142
219
|
// ---------------------------------------------------------------------------
|
|
143
220
|
// Dotpath helpers (pure, no side effects)
|
|
@@ -186,6 +263,16 @@ function setNestedValue(obj, dotpath, value) {
|
|
|
186
263
|
current[lastSegment] = value;
|
|
187
264
|
return result;
|
|
188
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Check if a string represents a numeric value (integer or decimal).
|
|
268
|
+
* Uses Number() instead of regex to avoid unsafe regex patterns.
|
|
269
|
+
*/
|
|
270
|
+
function isNumericString(value) {
|
|
271
|
+
if (value === '' || value.trim() !== value)
|
|
272
|
+
return false;
|
|
273
|
+
const num = Number(value);
|
|
274
|
+
return !isNaN(num) && isFinite(num);
|
|
275
|
+
}
|
|
189
276
|
/**
|
|
190
277
|
* Coerce a string value to the appropriate type based on context.
|
|
191
278
|
*
|
|
@@ -200,7 +287,7 @@ function coerceValue(value, existingValue) {
|
|
|
200
287
|
if (value === 'false')
|
|
201
288
|
return false;
|
|
202
289
|
// Number coercion (if existing value is a number or value looks numeric)
|
|
203
|
-
if (typeof existingValue === 'number' ||
|
|
290
|
+
if (typeof existingValue === 'number' || isNumericString(value)) {
|
|
204
291
|
const numValue = Number(value);
|
|
205
292
|
if (!isNaN(numValue))
|
|
206
293
|
return numValue;
|
|
@@ -215,31 +302,236 @@ function coerceValue(value, existingValue) {
|
|
|
215
302
|
return value;
|
|
216
303
|
}
|
|
217
304
|
// ---------------------------------------------------------------------------
|
|
218
|
-
//
|
|
305
|
+
// Unknown key detection (WU-2190)
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
/**
|
|
308
|
+
* Recursively compare an input object against a Zod-parsed output to find
|
|
309
|
+
* keys that were silently stripped during parsing (i.e., unknown keys).
|
|
310
|
+
*
|
|
311
|
+
* @param input - The pre-parse object (normalized config)
|
|
312
|
+
* @param parsed - The post-parse object (Zod output with defaults applied)
|
|
313
|
+
* @param prefix - Dot-notation prefix for nested paths
|
|
314
|
+
* @returns Array of dotpath strings for keys present in input but missing in parsed output
|
|
315
|
+
*/
|
|
316
|
+
function findStrippedKeys(input, parsed, prefix = '') {
|
|
317
|
+
const stripped = [];
|
|
318
|
+
for (const key of Object.keys(input)) {
|
|
319
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
320
|
+
if (!(key in parsed)) {
|
|
321
|
+
stripped.push(fullPath);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// Recurse into nested objects (skip arrays and non-objects)
|
|
325
|
+
const inputVal = input[key];
|
|
326
|
+
const parsedVal = parsed[key];
|
|
327
|
+
if (inputVal !== null &&
|
|
328
|
+
inputVal !== undefined &&
|
|
329
|
+
typeof inputVal === 'object' &&
|
|
330
|
+
!Array.isArray(inputVal) &&
|
|
331
|
+
parsedVal !== null &&
|
|
332
|
+
parsedVal !== undefined &&
|
|
333
|
+
typeof parsedVal === 'object' &&
|
|
334
|
+
!Array.isArray(parsedVal)) {
|
|
335
|
+
stripped.push(...findStrippedKeys(inputVal, parsedVal, fullPath));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return stripped;
|
|
339
|
+
}
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Core logic: applyConfigSet (workspace-aware routing)
|
|
219
342
|
// ---------------------------------------------------------------------------
|
|
220
343
|
/**
|
|
221
|
-
* Apply a config:set operation to a
|
|
344
|
+
* Apply a config:set operation to a full workspace object.
|
|
222
345
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* 3. Normalizes keys for Zod compatibility
|
|
226
|
-
* 4. Validates the resulting config against the Zod schema
|
|
346
|
+
* Routes the key by prefix, validates against the appropriate schema,
|
|
347
|
+
* and returns the updated workspace or an error.
|
|
227
348
|
*
|
|
228
|
-
* @param
|
|
229
|
-
* @param
|
|
349
|
+
* @param workspace - Full workspace object (raw YAML parse)
|
|
350
|
+
* @param key - Fully qualified dotpath key (e.g., "software_delivery.gates.minCoverage")
|
|
230
351
|
* @param rawValue - String value from CLI
|
|
231
|
-
* @
|
|
352
|
+
* @param packConfigKeys - Map of config_key -> pack_id from loaded pack manifests
|
|
353
|
+
* @returns Result with updated workspace or error
|
|
232
354
|
*/
|
|
233
|
-
export function applyConfigSet(
|
|
355
|
+
export function applyConfigSet(workspace, key, rawValue, packConfigKeys, packSchemaOpts) {
|
|
356
|
+
const route = routeConfigKey(key, packConfigKeys);
|
|
357
|
+
switch (route.type) {
|
|
358
|
+
case 'managed-error':
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
error: `Key "${route.rootKey}" is managed by a dedicated command. Use \`pnpm ${route.command}\` instead.`,
|
|
362
|
+
};
|
|
363
|
+
case 'unknown-error': {
|
|
364
|
+
const hint = route.suggestion ? ` ${route.suggestion}` : '';
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
error: `Unknown root key "${route.rootKey}".${hint} Valid root keys: ${[...WRITABLE_ROOT_KEYS].join(', ')}, or a pack config_key (${[...packConfigKeys.keys()].join(', ')}).`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
case 'workspace-root':
|
|
371
|
+
return applyWorkspaceRootSet(workspace, key, rawValue);
|
|
372
|
+
case 'pack-config':
|
|
373
|
+
return applyPackConfigSet(workspace, route, rawValue, packSchemaOpts);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Scalar-only root keys that must remain simple string values.
|
|
378
|
+
* Sub-path writes (e.g., memory_namespace.foo) are invalid for these keys.
|
|
379
|
+
*/
|
|
380
|
+
const SCALAR_ROOT_KEYS = new Set(['memory_namespace', 'event_namespace']);
|
|
381
|
+
/**
|
|
382
|
+
* Apply a set operation at the workspace root level.
|
|
383
|
+
* Used for WRITABLE_ROOT_KEYS like control_plane, memory_namespace, event_namespace.
|
|
384
|
+
*
|
|
385
|
+
* WU-2190: Validates root key writes against schemas/type constraints:
|
|
386
|
+
* - control_plane: validated against WorkspaceControlPlaneConfigSchema (.strict())
|
|
387
|
+
* - memory_namespace / event_namespace: must be strings, no sub-path writes
|
|
388
|
+
*/
|
|
389
|
+
function applyWorkspaceRootSet(workspace, key, rawValue) {
|
|
390
|
+
const segments = key.split('.');
|
|
391
|
+
const rootKey = segments[0];
|
|
392
|
+
const subPath = segments.slice(1).join('.');
|
|
393
|
+
// --- Scalar root keys (memory_namespace, event_namespace) ---
|
|
394
|
+
if (SCALAR_ROOT_KEYS.has(rootKey)) {
|
|
395
|
+
if (subPath) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: `"${rootKey}" is a scalar value and does not support sub-path writes. Use: config:set --key ${rootKey} --value <string>`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
// Scalar keys must remain strings
|
|
402
|
+
return { ok: true, config: setNestedValue(workspace, key, rawValue) };
|
|
403
|
+
}
|
|
404
|
+
// --- control_plane validation ---
|
|
405
|
+
if (rootKey === 'control_plane') {
|
|
406
|
+
// Reject overwriting the entire control_plane object with a scalar
|
|
407
|
+
if (!subPath) {
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
error: `Cannot overwrite "control_plane" with a scalar value. Set individual sub-keys instead (e.g., control_plane.sync_interval).`,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
// Build the candidate control_plane object with the new value applied
|
|
414
|
+
const existingCP = workspace.control_plane;
|
|
415
|
+
const cpConfig = existingCP && typeof existingCP === 'object' && !Array.isArray(existingCP)
|
|
416
|
+
? JSON.parse(JSON.stringify(existingCP))
|
|
417
|
+
: {};
|
|
418
|
+
const existingValue = getConfigValue(cpConfig, subPath);
|
|
419
|
+
const coercedValue = coerceValue(rawValue, existingValue);
|
|
420
|
+
const updatedCP = setNestedValue(cpConfig, subPath, coercedValue);
|
|
421
|
+
// Validate the updated control_plane against a partial-but-strict schema.
|
|
422
|
+
// partial() makes all fields optional (real configs may have only a subset),
|
|
423
|
+
// but .strict() still rejects unknown keys.
|
|
424
|
+
const parseResult = WorkspaceControlPlaneConfigSchema.partial().strict().safeParse(updatedCP);
|
|
425
|
+
if (!parseResult.success) {
|
|
426
|
+
const issues = parseResult.error.issues
|
|
427
|
+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
428
|
+
.join('; ');
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
error: `Validation failed for ${key}=${rawValue}: ${issues}`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const updatedWorkspace = { ...workspace, control_plane: updatedCP };
|
|
435
|
+
return { ok: true, config: updatedWorkspace };
|
|
436
|
+
}
|
|
437
|
+
// Fallback for any future writable root keys: apply without extra validation
|
|
438
|
+
const existingValue = getConfigValue(workspace, key);
|
|
439
|
+
const coercedValue = coerceValue(rawValue, existingValue);
|
|
440
|
+
const updatedWorkspace = setNestedValue(workspace, key, coercedValue);
|
|
441
|
+
return { ok: true, config: updatedWorkspace };
|
|
442
|
+
}
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// JSON Schema validation (WU-2192)
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
/**
|
|
447
|
+
* Walk a JSON Schema to validate that a dotpath corresponds to a known property.
|
|
448
|
+
*
|
|
449
|
+
* For each segment in the dotpath, checks if the current schema level has
|
|
450
|
+
* a `properties` entry for that segment. Returns the list of unrecognized
|
|
451
|
+
* segments as dotpath strings.
|
|
452
|
+
*
|
|
453
|
+
* This is a lightweight structural validation -- it checks key existence,
|
|
454
|
+
* not value types. Full JSON Schema validation (via ajv) is deferred to
|
|
455
|
+
* a future WU if needed.
|
|
456
|
+
*
|
|
457
|
+
* @param schema - Parsed JSON Schema object
|
|
458
|
+
* @param dotpath - Dot-separated path to validate (e.g., "metrics.interval")
|
|
459
|
+
* @returns Array of unrecognized dotpath segments, or empty array if valid
|
|
460
|
+
*/
|
|
461
|
+
function validateJsonSchemaPath(schema, dotpath) {
|
|
462
|
+
const segments = dotpath.split('.');
|
|
463
|
+
let currentSchema = schema;
|
|
464
|
+
const unrecognized = [];
|
|
465
|
+
for (let i = 0; i < segments.length; i++) {
|
|
466
|
+
const segment = segments[i];
|
|
467
|
+
const properties = currentSchema.properties;
|
|
468
|
+
if (!properties || !(segment in properties)) {
|
|
469
|
+
// This segment is not recognized at the current level
|
|
470
|
+
unrecognized.push(segments.slice(0, i + 1).join('.'));
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
// Move deeper into the schema
|
|
474
|
+
currentSchema = properties[segment];
|
|
475
|
+
}
|
|
476
|
+
return unrecognized;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Apply a set operation within a pack's config block.
|
|
480
|
+
* Extracts the pack section, applies the change, validates via Zod schema,
|
|
481
|
+
* then writes back into the workspace.
|
|
482
|
+
*/
|
|
483
|
+
function applyPackConfigSet(workspace, route, rawValue, packSchemaOpts) {
|
|
484
|
+
// Extract the pack's config section
|
|
485
|
+
const packSection = workspace[route.rootKey];
|
|
486
|
+
const packConfig = packSection && typeof packSection === 'object' && !Array.isArray(packSection)
|
|
487
|
+
? packSection
|
|
488
|
+
: {};
|
|
489
|
+
if (!route.subPath) {
|
|
490
|
+
return {
|
|
491
|
+
ok: false,
|
|
492
|
+
error: `Key must target a nested field under ${route.rootKey} (e.g., ${route.rootKey}.methodology.testing).`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
234
495
|
// Get existing value for type inference
|
|
235
|
-
const existingValue = getConfigValue(
|
|
496
|
+
const existingValue = getConfigValue(packConfig, route.subPath);
|
|
236
497
|
// Coerce value
|
|
237
498
|
const coercedValue = coerceValue(rawValue, existingValue);
|
|
238
|
-
// Set value in config
|
|
239
|
-
const
|
|
499
|
+
// Set value in pack config
|
|
500
|
+
const updatedPackConfig = setNestedValue(packConfig, route.subPath, coercedValue);
|
|
501
|
+
// WU-2192: Route validation by pack identity.
|
|
502
|
+
// SD pack uses the built-in LumenFlowConfigSchema (Zod).
|
|
503
|
+
// Non-SD packs with config_schema use JSON Schema validation.
|
|
504
|
+
// Non-SD packs without config_schema reject all writes.
|
|
505
|
+
if (route.packId === SD_PACK_ID || !packSchemaOpts) {
|
|
506
|
+
// SD pack path (or legacy callers without schema opts): use LumenFlowConfigSchema
|
|
507
|
+
return applyPackConfigSetWithZod(workspace, route, rawValue, updatedPackConfig);
|
|
508
|
+
}
|
|
509
|
+
// Non-SD pack path: check if the pack declares a config_schema
|
|
510
|
+
const hasSchema = packSchemaOpts.packSchemaMap.get(route.packId);
|
|
511
|
+
if (!hasSchema) {
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
error: `Pack "${route.packId}" declares no config_schema. Config writes to "${route.rootKey}" are rejected because the pack has not declared a schema for validation.`,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// Non-SD pack with config_schema: validate using JSON Schema
|
|
518
|
+
const jsonSchema = packSchemaOpts.jsonSchemas.get(route.packId);
|
|
519
|
+
if (!jsonSchema) {
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
error: `Pack "${route.packId}" declares a config_schema but the schema could not be loaded. Config writes to "${route.rootKey}" are rejected.`,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return applyPackConfigSetWithJsonSchema(workspace, route, updatedPackConfig, jsonSchema);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Validate a pack config write using LumenFlowConfigSchema (Zod).
|
|
529
|
+
* Used for the SD pack and legacy callers.
|
|
530
|
+
*/
|
|
531
|
+
function applyPackConfigSetWithZod(workspace, route, rawValue, updatedPackConfig) {
|
|
240
532
|
// Normalize keys for Zod compatibility (snake_case -> camelCase)
|
|
241
|
-
const normalized = normalizeConfigKeys(
|
|
242
|
-
// Validate against
|
|
533
|
+
const normalized = normalizeConfigKeys(updatedPackConfig);
|
|
534
|
+
// Validate against LumenFlowConfigSchema (SD pack schema)
|
|
243
535
|
const parseResult = LumenFlowConfigSchema.safeParse(normalized);
|
|
244
536
|
if (!parseResult.success) {
|
|
245
537
|
const issues = parseResult.error.issues
|
|
@@ -247,11 +539,68 @@ export function applyConfigSet(config, dotpath, rawValue) {
|
|
|
247
539
|
.join('; ');
|
|
248
540
|
return {
|
|
249
541
|
ok: false,
|
|
250
|
-
error: `Validation failed for ${
|
|
542
|
+
error: `Validation failed for ${route.rootKey}.${route.subPath}=${rawValue}: ${issues}`,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
// WU-2190: Detect unknown keys that Zod silently stripped during parsing.
|
|
546
|
+
// WU-2197: Scope the check to the sub-tree being written, not the full config.
|
|
547
|
+
// This prevents pre-existing unknown keys in unrelated paths from blocking writes.
|
|
548
|
+
const parsedData = parseResult.data;
|
|
549
|
+
const writeTargetKey = route.subPath.split('.')[0];
|
|
550
|
+
let strippedKeys;
|
|
551
|
+
if (writeTargetKey &&
|
|
552
|
+
typeof normalized[writeTargetKey] === 'object' &&
|
|
553
|
+
normalized[writeTargetKey] !== null &&
|
|
554
|
+
!Array.isArray(normalized[writeTargetKey]) &&
|
|
555
|
+
typeof parsedData[writeTargetKey] === 'object' &&
|
|
556
|
+
parsedData[writeTargetKey] !== null &&
|
|
557
|
+
!Array.isArray(parsedData[writeTargetKey])) {
|
|
558
|
+
// Nested write (e.g., methodology.testing): scope check to the sub-tree
|
|
559
|
+
strippedKeys = findStrippedKeys(normalized[writeTargetKey], parsedData[writeTargetKey], writeTargetKey);
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
// Top-level or scalar write: check only whether the target key itself was stripped
|
|
563
|
+
strippedKeys =
|
|
564
|
+
writeTargetKey && writeTargetKey in normalized && !(writeTargetKey in parsedData)
|
|
565
|
+
? [writeTargetKey]
|
|
566
|
+
: [];
|
|
567
|
+
}
|
|
568
|
+
if (strippedKeys.length > 0) {
|
|
569
|
+
return {
|
|
570
|
+
ok: false,
|
|
571
|
+
error: `Unknown config key(s) rejected: ${strippedKeys.join(', ')}. These keys are not recognized by the ${route.rootKey} schema.`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
// Write updated pack config back into workspace
|
|
575
|
+
const updatedWorkspace = {
|
|
576
|
+
...workspace,
|
|
577
|
+
[route.rootKey]: updatedPackConfig,
|
|
578
|
+
};
|
|
579
|
+
return { ok: true, config: updatedWorkspace };
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Validate a pack config write using a JSON Schema object.
|
|
583
|
+
* Used for non-SD packs that declare config_schema in their manifest.
|
|
584
|
+
*
|
|
585
|
+
* This performs structural validation: checks that the dotpath corresponds
|
|
586
|
+
* to a known property in the JSON Schema. Full type validation is deferred
|
|
587
|
+
* to a future WU if needed (would require ajv or similar).
|
|
588
|
+
*/
|
|
589
|
+
function applyPackConfigSetWithJsonSchema(workspace, route, updatedPackConfig, jsonSchema) {
|
|
590
|
+
// Validate the subPath against the JSON Schema structure
|
|
591
|
+
const unrecognized = validateJsonSchemaPath(jsonSchema, route.subPath);
|
|
592
|
+
if (unrecognized.length > 0) {
|
|
593
|
+
return {
|
|
594
|
+
ok: false,
|
|
595
|
+
error: `Unknown config key(s) rejected for pack "${route.packId}": ${unrecognized.join(', ')}. These keys are not recognized by the ${route.rootKey} schema.`,
|
|
251
596
|
};
|
|
252
597
|
}
|
|
253
|
-
//
|
|
254
|
-
|
|
598
|
+
// Write updated pack config back into workspace
|
|
599
|
+
const updatedWorkspace = {
|
|
600
|
+
...workspace,
|
|
601
|
+
[route.rootKey]: updatedPackConfig,
|
|
602
|
+
};
|
|
603
|
+
return { ok: true, config: updatedWorkspace };
|
|
255
604
|
}
|
|
256
605
|
// ---------------------------------------------------------------------------
|
|
257
606
|
// Config I/O helpers
|
|
@@ -278,30 +627,71 @@ function writeRawWorkspace(workspacePath, workspace) {
|
|
|
278
627
|
writeFileSync(workspacePath, nextContent, FILE_SYSTEM.UTF8);
|
|
279
628
|
}
|
|
280
629
|
/**
|
|
281
|
-
*
|
|
630
|
+
* Load pack config_keys from workspace packs field.
|
|
631
|
+
* Reads pinned pack manifests to discover their declared config_key.
|
|
282
632
|
*
|
|
633
|
+
* Delegates to the shared pack manifest resolver in @lumenflow/kernel
|
|
634
|
+
* which handles all pack source types (local, registry, git) instead
|
|
635
|
+
* of hardcoding a monorepo-only path.
|
|
636
|
+
*
|
|
637
|
+
* @param projectRoot - Absolute path to project root
|
|
283
638
|
* @param workspace - Parsed workspace object
|
|
284
|
-
* @returns
|
|
639
|
+
* @returns Map of config_key -> pack_id
|
|
285
640
|
*/
|
|
286
|
-
export function
|
|
287
|
-
const
|
|
288
|
-
if (!
|
|
289
|
-
return
|
|
641
|
+
export function loadPackConfigKeys(projectRoot, workspace) {
|
|
642
|
+
const packs = workspace.packs;
|
|
643
|
+
if (!Array.isArray(packs)) {
|
|
644
|
+
return new Map();
|
|
290
645
|
}
|
|
291
|
-
return
|
|
646
|
+
return resolvePackManifestPaths({ projectRoot, packs });
|
|
292
647
|
}
|
|
293
648
|
/**
|
|
294
|
-
*
|
|
649
|
+
* Load pack schema options for pack-aware validation (WU-2192).
|
|
650
|
+
*
|
|
651
|
+
* Reads pack manifests to discover config_schema declarations, then loads
|
|
652
|
+
* the referenced JSON Schema files from disk. The SD pack is always marked
|
|
653
|
+
* as having a schema (its built-in Zod schema is used instead).
|
|
295
654
|
*
|
|
296
|
-
* @param
|
|
297
|
-
* @param
|
|
298
|
-
* @returns
|
|
655
|
+
* @param projectRoot - Absolute path to project root
|
|
656
|
+
* @param workspace - Parsed workspace object
|
|
657
|
+
* @returns PackSchemaOpts for use with applyConfigSet
|
|
299
658
|
*/
|
|
300
|
-
export function
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
659
|
+
export function loadPackSchemaOpts(projectRoot, workspace) {
|
|
660
|
+
const packs = workspace.packs;
|
|
661
|
+
const packSchemaMap = new Map();
|
|
662
|
+
const jsonSchemas = new Map();
|
|
663
|
+
if (!Array.isArray(packs)) {
|
|
664
|
+
return { packSchemaMap, jsonSchemas };
|
|
665
|
+
}
|
|
666
|
+
// The SD pack always has a schema (built-in Zod)
|
|
667
|
+
packSchemaMap.set(SD_PACK_ID, true);
|
|
668
|
+
const schemaMetadata = resolvePackSchemaMetadata({ projectRoot, packs });
|
|
669
|
+
for (const [packId, meta] of schemaMetadata) {
|
|
670
|
+
if (packId === SD_PACK_ID) {
|
|
671
|
+
// SD pack schema is built-in, already marked above
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
if (meta.hasSchema && meta.schemaPath) {
|
|
675
|
+
packSchemaMap.set(packId, true);
|
|
676
|
+
// Try to load the JSON Schema file
|
|
677
|
+
if (existsSync(meta.schemaPath)) {
|
|
678
|
+
try {
|
|
679
|
+
const schemaContent = readFileSync(meta.schemaPath, 'utf8');
|
|
680
|
+
const parsed = JSON.parse(schemaContent);
|
|
681
|
+
jsonSchemas.set(packId, parsed);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Schema file exists but is unreadable/invalid
|
|
685
|
+
// packSchemaMap still says true, but jsonSchemas won't have it.
|
|
686
|
+
// applyPackConfigSet handles this case with a clear error.
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
packSchemaMap.set(packId, false);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { packSchemaMap, jsonSchemas };
|
|
305
695
|
}
|
|
306
696
|
// ---------------------------------------------------------------------------
|
|
307
697
|
// Main: config:set
|
|
@@ -309,15 +699,23 @@ export function setSoftwareDeliveryConfigInWorkspace(workspace, config) {
|
|
|
309
699
|
export async function main() {
|
|
310
700
|
const userArgs = process.argv.slice(2);
|
|
311
701
|
const options = parseConfigSetArgs(userArgs);
|
|
312
|
-
const configKey = normalizeWorkspaceConfigKey(options.key);
|
|
313
|
-
if (!configKey) {
|
|
314
|
-
die(`${LOG_PREFIX} Key must target a nested field under ${WORKSPACE_CONFIG_ROOT_KEY} (e.g., ${WORKSPACE_CONFIG_PREFIX}methodology.testing).`);
|
|
315
|
-
}
|
|
316
702
|
const projectRoot = findProjectRoot();
|
|
317
703
|
const workspacePath = path.join(projectRoot, WORKSPACE_FILE_NAME);
|
|
318
704
|
if (!existsSync(workspacePath)) {
|
|
319
705
|
die(`${LOG_PREFIX} Missing ${WORKSPACE_FILE_NAME}. Run \`${WORKSPACE_INIT_COMMAND}\` first.`);
|
|
320
706
|
}
|
|
707
|
+
// Load pack config_keys from workspace for routing
|
|
708
|
+
const rawWorkspace = readRawWorkspace(workspacePath);
|
|
709
|
+
const packConfigKeys = loadPackConfigKeys(projectRoot, rawWorkspace);
|
|
710
|
+
// Validate routing before starting micro-worktree
|
|
711
|
+
const route = routeConfigKey(options.key, packConfigKeys);
|
|
712
|
+
if (route.type === 'managed-error') {
|
|
713
|
+
die(`${LOG_PREFIX} Key "${route.rootKey}" is managed by a dedicated command. Use \`pnpm ${route.command}\` instead.`);
|
|
714
|
+
}
|
|
715
|
+
if (route.type === 'unknown-error') {
|
|
716
|
+
const hint = route.suggestion ? ` ${route.suggestion}` : '';
|
|
717
|
+
die(`${LOG_PREFIX} Unknown root key "${route.rootKey}".${hint} Valid root keys: ${[...WRITABLE_ROOT_KEYS].join(', ')}, or a pack config_key (${[...packConfigKeys.keys()].join(', ')}).`);
|
|
718
|
+
}
|
|
321
719
|
console.log(`${LOG_PREFIX} Setting ${options.key}=${options.value} in ${WORKSPACE_FILE_NAME} via micro-worktree isolation`);
|
|
322
720
|
// Use micro-worktree to make atomic changes
|
|
323
721
|
await withMicroWorktree({
|
|
@@ -331,17 +729,18 @@ export async function main() {
|
|
|
331
729
|
if (!existsSync(mwWorkspacePath)) {
|
|
332
730
|
die(`${LOG_PREFIX} Config file not found in micro-worktree: ${workspaceRelPath}`);
|
|
333
731
|
}
|
|
334
|
-
// Read workspace
|
|
732
|
+
// Read full workspace
|
|
335
733
|
const workspace = readRawWorkspace(mwWorkspacePath);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
734
|
+
// Re-load pack config keys and schema opts from micro-worktree workspace
|
|
735
|
+
const mwPackConfigKeys = loadPackConfigKeys(worktreePath, workspace);
|
|
736
|
+
const mwPackSchemaOpts = loadPackSchemaOpts(worktreePath, workspace);
|
|
737
|
+
// Apply set with workspace-aware routing and pack-specific validation
|
|
738
|
+
const result = applyConfigSet(workspace, options.key, options.value, mwPackConfigKeys, mwPackSchemaOpts);
|
|
339
739
|
if (!result.ok) {
|
|
340
740
|
die(`${LOG_PREFIX} ${result.error}`);
|
|
341
741
|
}
|
|
342
|
-
// Write updated workspace
|
|
343
|
-
|
|
344
|
-
writeRawWorkspace(mwWorkspacePath, updatedWorkspace);
|
|
742
|
+
// Write updated workspace
|
|
743
|
+
writeRawWorkspace(mwWorkspacePath, result.config);
|
|
345
744
|
console.log(`${LOG_PREFIX} Config validated and written successfully.`);
|
|
346
745
|
return {
|
|
347
746
|
commitMessage: `${COMMIT_PREFIX} ${options.key}=${options.value}`,
|