@workflow-cannon/workspace-kit 0.3.0 → 0.4.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 +3 -3
- package/dist/cli.js +115 -3
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/module-contract.d.ts +14 -0
- package/dist/core/config-cli.d.ts +6 -0
- package/dist/core/config-cli.js +479 -0
- package/dist/core/config-metadata.d.ts +35 -0
- package/dist/core/config-metadata.js +162 -0
- package/dist/core/config-mutations.d.ts +18 -0
- package/dist/core/config-mutations.js +32 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +5 -0
- package/dist/core/policy.d.ts +34 -0
- package/dist/core/policy.js +135 -0
- package/dist/core/workspace-kit-config.d.ts +57 -0
- package/dist/core/workspace-kit-config.js +266 -0
- package/dist/modules/index.d.ts +1 -0
- package/dist/modules/index.js +1 -0
- package/dist/modules/task-engine/index.js +15 -3
- package/dist/modules/workspace-config/index.d.ts +2 -0
- package/dist/modules/workspace-config/index.js +100 -0
- package/package.json +1 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as processStdin, stdout as processStdout } from "node:process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ModuleRegistry } from "./module-registry.js";
|
|
6
|
+
import { appendPolicyTrace, parsePolicyApprovalFromEnv, resolveActor } from "./policy.js";
|
|
7
|
+
import { appendConfigMutation, summarizeForEvidence } from "./config-mutations.js";
|
|
8
|
+
import { assertWritableKey, getConfigKeyMetadata, listConfigMetadata, validatePersistedConfigDocument, validateValueForMetadata } from "./config-metadata.js";
|
|
9
|
+
import { explainConfigPath, getAtPath, getProjectConfigPath, getUserConfigFilePath, resolveWorkspaceConfigWithLayers, stableStringifyConfig } from "./workspace-kit-config.js";
|
|
10
|
+
import { workspaceConfigModule } from "../modules/workspace-config/index.js";
|
|
11
|
+
import { documentationModule } from "../modules/documentation/index.js";
|
|
12
|
+
import { taskEngineModule } from "../modules/task-engine/index.js";
|
|
13
|
+
import { approvalsModule } from "../modules/approvals/index.js";
|
|
14
|
+
import { planningModule } from "../modules/planning/index.js";
|
|
15
|
+
import { improvementModule } from "../modules/improvement/index.js";
|
|
16
|
+
const EXIT_SUCCESS = 0;
|
|
17
|
+
const EXIT_VALIDATION_FAILURE = 1;
|
|
18
|
+
const EXIT_USAGE_ERROR = 2;
|
|
19
|
+
const EXIT_INTERNAL_ERROR = 3;
|
|
20
|
+
function cloneCfg(obj) {
|
|
21
|
+
return JSON.parse(JSON.stringify(obj));
|
|
22
|
+
}
|
|
23
|
+
function setDeep(root, dotted, value) {
|
|
24
|
+
const out = cloneCfg(root);
|
|
25
|
+
const parts = dotted.split(".").filter(Boolean);
|
|
26
|
+
let cur = out;
|
|
27
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
28
|
+
const p = parts[i];
|
|
29
|
+
const next = cur[p];
|
|
30
|
+
if (next === undefined || typeof next !== "object" || Array.isArray(next)) {
|
|
31
|
+
cur[p] = {};
|
|
32
|
+
}
|
|
33
|
+
cur = cur[p];
|
|
34
|
+
}
|
|
35
|
+
cur[parts[parts.length - 1]] = value;
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
function unsetDeep(root, dotted) {
|
|
39
|
+
const out = cloneCfg(root);
|
|
40
|
+
const parts = dotted.split(".").filter(Boolean);
|
|
41
|
+
if (parts.length === 0)
|
|
42
|
+
return out;
|
|
43
|
+
const parents = [out];
|
|
44
|
+
let cur = out;
|
|
45
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
46
|
+
const p = parts[i];
|
|
47
|
+
const next = cur[p];
|
|
48
|
+
if (next === undefined || typeof next !== "object" || Array.isArray(next)) {
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
cur = next;
|
|
52
|
+
parents.push(cur);
|
|
53
|
+
}
|
|
54
|
+
const leaf = parts[parts.length - 1];
|
|
55
|
+
delete cur[leaf];
|
|
56
|
+
// Prune empty objects bottom-up
|
|
57
|
+
for (let i = parents.length - 1; i >= 1; i--) {
|
|
58
|
+
const childKey = parts[i - 1];
|
|
59
|
+
const parent = parents[i - 1];
|
|
60
|
+
const child = parent[childKey];
|
|
61
|
+
if (child &&
|
|
62
|
+
typeof child === "object" &&
|
|
63
|
+
!Array.isArray(child) &&
|
|
64
|
+
Object.keys(child).length === 0) {
|
|
65
|
+
delete parent[childKey];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
async function readJsonFileOrEmpty(fp) {
|
|
71
|
+
try {
|
|
72
|
+
const raw = await fs.readFile(fp, "utf8");
|
|
73
|
+
const parsed = JSON.parse(raw);
|
|
74
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
75
|
+
throw new Error(`invalid JSON object: ${fp}`);
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
const err = e;
|
|
81
|
+
if (err.code === "ENOENT") {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function writeConfigFileAtomic(fp, data) {
|
|
88
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
89
|
+
const tmp = `${fp}.${process.pid}.${Date.now()}.tmp`;
|
|
90
|
+
await fs.writeFile(tmp, stableStringifyConfig(data), "utf8");
|
|
91
|
+
await fs.rename(tmp, fp);
|
|
92
|
+
}
|
|
93
|
+
function buildRegistry() {
|
|
94
|
+
const allModules = [
|
|
95
|
+
workspaceConfigModule,
|
|
96
|
+
documentationModule,
|
|
97
|
+
taskEngineModule,
|
|
98
|
+
approvalsModule,
|
|
99
|
+
planningModule,
|
|
100
|
+
improvementModule
|
|
101
|
+
];
|
|
102
|
+
return new ModuleRegistry(allModules);
|
|
103
|
+
}
|
|
104
|
+
function parseConfigArgs(argv) {
|
|
105
|
+
const json = argv.includes("--json");
|
|
106
|
+
const parts = argv.filter((a) => a !== "--json");
|
|
107
|
+
return { json, parts };
|
|
108
|
+
}
|
|
109
|
+
async function requireConfigApproval(cwd, commandLabel, writeError) {
|
|
110
|
+
const approval = parsePolicyApprovalFromEnv(process.env);
|
|
111
|
+
if (!approval) {
|
|
112
|
+
writeError(`${commandLabel} requires WORKSPACE_KIT_POLICY_APPROVAL with JSON {"confirmed":true,"rationale":"..."}.`);
|
|
113
|
+
await appendPolicyTrace(cwd, {
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
operationId: "cli.config-mutate",
|
|
116
|
+
command: commandLabel,
|
|
117
|
+
actor: resolveActor(cwd, {}, process.env),
|
|
118
|
+
allowed: false,
|
|
119
|
+
message: "missing WORKSPACE_KIT_POLICY_APPROVAL"
|
|
120
|
+
});
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return approval;
|
|
124
|
+
}
|
|
125
|
+
export async function generateConfigReferenceDocs(workspacePath) {
|
|
126
|
+
const meta = listConfigMetadata({ exposure: "maintainer" });
|
|
127
|
+
const aiPath = path.join(workspacePath, ".ai", "CONFIG.md");
|
|
128
|
+
const humanPath = path.join(workspacePath, "docs", "maintainers", "CONFIG.md");
|
|
129
|
+
const lines = (audience) => {
|
|
130
|
+
const out = [
|
|
131
|
+
`# Config reference (${audience})`,
|
|
132
|
+
"",
|
|
133
|
+
"Generated from `src/core/config-metadata.ts`. Do not edit by hand; run `workspace-kit config generate-docs`.",
|
|
134
|
+
"",
|
|
135
|
+
"| Key | Type | Default | Scope | Module | Exposure | Sensitive | Approval |",
|
|
136
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |"
|
|
137
|
+
];
|
|
138
|
+
for (const m of meta) {
|
|
139
|
+
out.push(`| ${m.key} | ${m.type} | ${JSON.stringify(m.default)} | ${m.domainScope} | ${m.owningModule} | ${m.exposure} | ${m.sensitive} | ${m.requiresApproval} |`);
|
|
140
|
+
out.push("");
|
|
141
|
+
out.push(`**Description:** ${m.description}`);
|
|
142
|
+
out.push("");
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
};
|
|
146
|
+
await fs.mkdir(path.dirname(aiPath), { recursive: true });
|
|
147
|
+
await fs.mkdir(path.dirname(humanPath), { recursive: true });
|
|
148
|
+
await fs.writeFile(aiPath, lines("ai").join("\n") + "\n", "utf8");
|
|
149
|
+
await fs.writeFile(humanPath, lines("human").join("\n") + "\n", "utf8");
|
|
150
|
+
}
|
|
151
|
+
export async function runWorkspaceConfigCli(cwd, argv, io) {
|
|
152
|
+
const { writeLine, writeError } = io;
|
|
153
|
+
const { json, parts } = parseConfigArgs(argv);
|
|
154
|
+
const sub = parts[0];
|
|
155
|
+
const tail = parts.slice(1);
|
|
156
|
+
if (!sub) {
|
|
157
|
+
writeError("Usage: workspace-kit config <list|get|set|unset|explain|validate|resolve|generate-docs|edit> [--json] ...");
|
|
158
|
+
return EXIT_USAGE_ERROR;
|
|
159
|
+
}
|
|
160
|
+
let registry;
|
|
161
|
+
try {
|
|
162
|
+
registry = buildRegistry();
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
writeError(e instanceof Error ? e.message : String(e));
|
|
166
|
+
return EXIT_INTERNAL_ERROR;
|
|
167
|
+
}
|
|
168
|
+
const emit = (obj) => {
|
|
169
|
+
writeLine(JSON.stringify(obj, null, json ? 2 : undefined));
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
if (sub === "list") {
|
|
173
|
+
const all = tail.includes("--all");
|
|
174
|
+
const exposure = all ? "maintainer" : "public";
|
|
175
|
+
const rows = listConfigMetadata({ exposure });
|
|
176
|
+
if (json) {
|
|
177
|
+
emit({ ok: true, code: "config-list", data: { keys: rows } });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
writeLine("Known config keys:");
|
|
181
|
+
for (const r of rows) {
|
|
182
|
+
writeLine(` ${r.key} (${r.type}) — ${r.description}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return EXIT_SUCCESS;
|
|
186
|
+
}
|
|
187
|
+
if (sub === "get") {
|
|
188
|
+
const key = tail[0];
|
|
189
|
+
if (!key) {
|
|
190
|
+
writeError("config get <key>");
|
|
191
|
+
return EXIT_USAGE_ERROR;
|
|
192
|
+
}
|
|
193
|
+
const { effective } = await resolveWorkspaceConfigWithLayers({
|
|
194
|
+
workspacePath: cwd,
|
|
195
|
+
registry
|
|
196
|
+
});
|
|
197
|
+
const val = getAtPath(effective, key);
|
|
198
|
+
if (json) {
|
|
199
|
+
emit({ ok: true, code: "config-get", data: { key, value: val } });
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
writeLine(String(JSON.stringify({ key, value: val }, null, 2)));
|
|
203
|
+
}
|
|
204
|
+
return EXIT_SUCCESS;
|
|
205
|
+
}
|
|
206
|
+
if (sub === "resolve") {
|
|
207
|
+
const { effective } = await resolveWorkspaceConfigWithLayers({
|
|
208
|
+
workspacePath: cwd,
|
|
209
|
+
registry
|
|
210
|
+
});
|
|
211
|
+
writeLine(stableStringifyConfig(effective).trimEnd());
|
|
212
|
+
return EXIT_SUCCESS;
|
|
213
|
+
}
|
|
214
|
+
if (sub === "validate") {
|
|
215
|
+
const projectPath = getProjectConfigPath(cwd);
|
|
216
|
+
const userPath = getUserConfigFilePath();
|
|
217
|
+
const p = await readJsonFileOrEmpty(projectPath);
|
|
218
|
+
const u = await readJsonFileOrEmpty(userPath);
|
|
219
|
+
validatePersistedConfigDocument(p, ".workspace-kit/config.json");
|
|
220
|
+
validatePersistedConfigDocument(u, "user config");
|
|
221
|
+
await resolveWorkspaceConfigWithLayers({ workspacePath: cwd, registry });
|
|
222
|
+
if (json) {
|
|
223
|
+
emit({ ok: true, code: "config-validated", data: { projectPath, userPath } });
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
writeLine("Config validate passed (project + user + merged resolution).");
|
|
227
|
+
}
|
|
228
|
+
return EXIT_SUCCESS;
|
|
229
|
+
}
|
|
230
|
+
if (sub === "explain") {
|
|
231
|
+
const key = tail[0];
|
|
232
|
+
if (!key) {
|
|
233
|
+
writeError("config explain <key>");
|
|
234
|
+
return EXIT_USAGE_ERROR;
|
|
235
|
+
}
|
|
236
|
+
const { layers } = await resolveWorkspaceConfigWithLayers({
|
|
237
|
+
workspacePath: cwd,
|
|
238
|
+
registry
|
|
239
|
+
});
|
|
240
|
+
const explained = explainConfigPath(key, layers);
|
|
241
|
+
const meta = getConfigKeyMetadata(key);
|
|
242
|
+
const data = {
|
|
243
|
+
...explained,
|
|
244
|
+
metadata: meta ?? null
|
|
245
|
+
};
|
|
246
|
+
if (json) {
|
|
247
|
+
emit({ ok: true, code: "config-explained", data });
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
writeLine(JSON.stringify(data, null, 2));
|
|
251
|
+
}
|
|
252
|
+
return EXIT_SUCCESS;
|
|
253
|
+
}
|
|
254
|
+
if (sub === "generate-docs") {
|
|
255
|
+
await generateConfigReferenceDocs(cwd);
|
|
256
|
+
if (json) {
|
|
257
|
+
emit({ ok: true, code: "config-docs-generated", data: { ai: ".ai/CONFIG.md", human: "docs/maintainers/CONFIG.md" } });
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
writeLine("Wrote .ai/CONFIG.md and docs/maintainers/CONFIG.md");
|
|
261
|
+
}
|
|
262
|
+
return EXIT_SUCCESS;
|
|
263
|
+
}
|
|
264
|
+
if (sub === "set") {
|
|
265
|
+
let scope = "project";
|
|
266
|
+
const args = [...tail];
|
|
267
|
+
if (args[0] === "--scope" && args[1]) {
|
|
268
|
+
scope = args[1];
|
|
269
|
+
if (scope !== "project" && scope !== "user") {
|
|
270
|
+
writeError("--scope must be project or user");
|
|
271
|
+
return EXIT_USAGE_ERROR;
|
|
272
|
+
}
|
|
273
|
+
args.splice(0, 2);
|
|
274
|
+
}
|
|
275
|
+
const key = args[0];
|
|
276
|
+
const jsonLiteral = args[1];
|
|
277
|
+
if (!key || jsonLiteral === undefined) {
|
|
278
|
+
writeError('config set [--scope project|user] <key> <jsonValue> e.g. \'".workspace-kit/tasks/state.json"\'');
|
|
279
|
+
return EXIT_USAGE_ERROR;
|
|
280
|
+
}
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = JSON.parse(jsonLiteral);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
writeError("config set: jsonValue must be valid JSON");
|
|
287
|
+
return EXIT_USAGE_ERROR;
|
|
288
|
+
}
|
|
289
|
+
const meta = assertWritableKey(key);
|
|
290
|
+
if (!meta.writableLayers.includes(scope)) {
|
|
291
|
+
writeError(`config set: key '${key}' cannot be written to layer '${scope}'`);
|
|
292
|
+
return EXIT_VALIDATION_FAILURE;
|
|
293
|
+
}
|
|
294
|
+
validateValueForMetadata(meta, parsed);
|
|
295
|
+
if (meta.requiresApproval || meta.sensitive) {
|
|
296
|
+
const approval = await requireConfigApproval(cwd, `config set ${key}`, writeError);
|
|
297
|
+
if (!approval)
|
|
298
|
+
return EXIT_VALIDATION_FAILURE;
|
|
299
|
+
}
|
|
300
|
+
const fp = scope === "project" ? getProjectConfigPath(cwd) : getUserConfigFilePath();
|
|
301
|
+
const before = await readJsonFileOrEmpty(fp);
|
|
302
|
+
validatePersistedConfigDocument(before, scope === "project" ? ".workspace-kit/config.json" : "user config");
|
|
303
|
+
const prevVal = getAtPath(before, key);
|
|
304
|
+
const next = setDeep(before, key, parsed);
|
|
305
|
+
validatePersistedConfigDocument(next, scope === "project" ? ".workspace-kit/config.json" : "user config");
|
|
306
|
+
const actor = resolveActor(cwd, {}, process.env);
|
|
307
|
+
try {
|
|
308
|
+
await writeConfigFileAtomic(fp, next);
|
|
309
|
+
await appendConfigMutation(cwd, {
|
|
310
|
+
timestamp: new Date().toISOString(),
|
|
311
|
+
actor,
|
|
312
|
+
key,
|
|
313
|
+
layer: scope,
|
|
314
|
+
operation: "set",
|
|
315
|
+
ok: true,
|
|
316
|
+
previousSummary: summarizeForEvidence(key, meta.sensitive, prevVal),
|
|
317
|
+
nextSummary: summarizeForEvidence(key, meta.sensitive, parsed)
|
|
318
|
+
});
|
|
319
|
+
if (meta.requiresApproval || meta.sensitive) {
|
|
320
|
+
const appr = parsePolicyApprovalFromEnv(process.env);
|
|
321
|
+
await appendPolicyTrace(cwd, {
|
|
322
|
+
timestamp: new Date().toISOString(),
|
|
323
|
+
operationId: "cli.config-mutate",
|
|
324
|
+
command: `config set ${key}`,
|
|
325
|
+
actor,
|
|
326
|
+
allowed: true,
|
|
327
|
+
rationale: appr.rationale,
|
|
328
|
+
commandOk: true
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (json) {
|
|
332
|
+
emit({ ok: true, code: "config-set", data: { key, scope } });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
writeLine(`Set ${key} on ${scope} layer.`);
|
|
336
|
+
}
|
|
337
|
+
return EXIT_SUCCESS;
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
341
|
+
await appendConfigMutation(cwd, {
|
|
342
|
+
timestamp: new Date().toISOString(),
|
|
343
|
+
actor,
|
|
344
|
+
key,
|
|
345
|
+
layer: scope,
|
|
346
|
+
operation: "set",
|
|
347
|
+
ok: false,
|
|
348
|
+
code: "config-set-failed",
|
|
349
|
+
message: msg
|
|
350
|
+
});
|
|
351
|
+
writeError(msg);
|
|
352
|
+
return EXIT_VALIDATION_FAILURE;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (sub === "unset") {
|
|
356
|
+
let scope = "project";
|
|
357
|
+
const args = [...tail];
|
|
358
|
+
if (args[0] === "--scope" && args[1]) {
|
|
359
|
+
scope = args[1];
|
|
360
|
+
args.splice(0, 2);
|
|
361
|
+
}
|
|
362
|
+
const key = args[0];
|
|
363
|
+
if (!key) {
|
|
364
|
+
writeError("config unset [--scope project|user] <key>");
|
|
365
|
+
return EXIT_USAGE_ERROR;
|
|
366
|
+
}
|
|
367
|
+
const meta = assertWritableKey(key);
|
|
368
|
+
if (!meta.writableLayers.includes(scope)) {
|
|
369
|
+
writeError(`config unset: key '${key}' not in layer '${scope}'`);
|
|
370
|
+
return EXIT_VALIDATION_FAILURE;
|
|
371
|
+
}
|
|
372
|
+
if (meta.requiresApproval || meta.sensitive) {
|
|
373
|
+
const approval = await requireConfigApproval(cwd, `config unset ${key}`, writeError);
|
|
374
|
+
if (!approval)
|
|
375
|
+
return EXIT_VALIDATION_FAILURE;
|
|
376
|
+
}
|
|
377
|
+
const fp = scope === "project" ? getProjectConfigPath(cwd) : getUserConfigFilePath();
|
|
378
|
+
const before = await readJsonFileOrEmpty(fp);
|
|
379
|
+
validatePersistedConfigDocument(before, scope === "project" ? ".workspace-kit/config.json" : "user config");
|
|
380
|
+
const prevVal = getAtPath(before, key);
|
|
381
|
+
const next = unsetDeep(before, key);
|
|
382
|
+
validatePersistedConfigDocument(next, scope === "project" ? ".workspace-kit/config.json" : "user config");
|
|
383
|
+
const actor = resolveActor(cwd, {}, process.env);
|
|
384
|
+
try {
|
|
385
|
+
if (Object.keys(next).length === 0) {
|
|
386
|
+
await fs.rm(fp, { force: true });
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
await writeConfigFileAtomic(fp, next);
|
|
390
|
+
}
|
|
391
|
+
await appendConfigMutation(cwd, {
|
|
392
|
+
timestamp: new Date().toISOString(),
|
|
393
|
+
actor,
|
|
394
|
+
key,
|
|
395
|
+
layer: scope,
|
|
396
|
+
operation: "unset",
|
|
397
|
+
ok: true,
|
|
398
|
+
previousSummary: summarizeForEvidence(key, meta.sensitive, prevVal),
|
|
399
|
+
nextSummary: summarizeForEvidence(key, meta.sensitive, undefined)
|
|
400
|
+
});
|
|
401
|
+
if (meta.requiresApproval || meta.sensitive) {
|
|
402
|
+
const appr = parsePolicyApprovalFromEnv(process.env);
|
|
403
|
+
await appendPolicyTrace(cwd, {
|
|
404
|
+
timestamp: new Date().toISOString(),
|
|
405
|
+
operationId: "cli.config-mutate",
|
|
406
|
+
command: `config unset ${key}`,
|
|
407
|
+
actor,
|
|
408
|
+
allowed: true,
|
|
409
|
+
rationale: appr.rationale,
|
|
410
|
+
commandOk: true
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (json) {
|
|
414
|
+
emit({ ok: true, code: "config-unset", data: { key, scope } });
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
writeLine(`Unset ${key} on ${scope} layer.`);
|
|
418
|
+
}
|
|
419
|
+
return EXIT_SUCCESS;
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
423
|
+
await appendConfigMutation(cwd, {
|
|
424
|
+
timestamp: new Date().toISOString(),
|
|
425
|
+
actor,
|
|
426
|
+
key,
|
|
427
|
+
layer: scope,
|
|
428
|
+
operation: "unset",
|
|
429
|
+
ok: false,
|
|
430
|
+
message: msg
|
|
431
|
+
});
|
|
432
|
+
writeError(msg);
|
|
433
|
+
return EXIT_VALIDATION_FAILURE;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (sub === "edit") {
|
|
437
|
+
if (!processStdin.isTTY) {
|
|
438
|
+
writeError("config edit requires an interactive TTY");
|
|
439
|
+
return EXIT_USAGE_ERROR;
|
|
440
|
+
}
|
|
441
|
+
const metaList = listConfigMetadata({ exposure: "public" });
|
|
442
|
+
writeLine("Select a key (number):");
|
|
443
|
+
metaList.forEach((m, i) => writeLine(` ${i + 1}. ${m.key} — ${m.description}`));
|
|
444
|
+
const rl = createInterface({ input: processStdin, output: processStdout });
|
|
445
|
+
const choice = await rl.question("> ");
|
|
446
|
+
rl.close();
|
|
447
|
+
const n = Number.parseInt(choice.trim(), 10);
|
|
448
|
+
if (!Number.isFinite(n) || n < 1 || n > metaList.length) {
|
|
449
|
+
writeError("Invalid selection");
|
|
450
|
+
return EXIT_USAGE_ERROR;
|
|
451
|
+
}
|
|
452
|
+
const meta = metaList[n - 1];
|
|
453
|
+
const { effective } = await resolveWorkspaceConfigWithLayers({ workspacePath: cwd, registry });
|
|
454
|
+
const current = getAtPath(effective, meta.key);
|
|
455
|
+
writeLine(`Current: ${JSON.stringify(current)} Default: ${JSON.stringify(meta.default)}`);
|
|
456
|
+
const rl2 = createInterface({ input: processStdin, output: processStdout });
|
|
457
|
+
const raw = await rl2.question("New JSON value: ");
|
|
458
|
+
rl2.close();
|
|
459
|
+
let parsed;
|
|
460
|
+
try {
|
|
461
|
+
parsed = JSON.parse(raw);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
writeError("Invalid JSON");
|
|
465
|
+
return EXIT_USAGE_ERROR;
|
|
466
|
+
}
|
|
467
|
+
validateValueForMetadata(meta, parsed);
|
|
468
|
+
const scope = meta.writableLayers.includes("project") ? "project" : "user";
|
|
469
|
+
return runWorkspaceConfigCli(cwd, ["set", "--scope", scope, meta.key, JSON.stringify(parsed)], io);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
474
|
+
writeError(msg);
|
|
475
|
+
return EXIT_VALIDATION_FAILURE;
|
|
476
|
+
}
|
|
477
|
+
writeError(`Unknown config subcommand '${sub}'`);
|
|
478
|
+
return EXIT_USAGE_ERROR;
|
|
479
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical metadata for user-facing workspace config keys (Phase 2b).
|
|
3
|
+
* CLI, explain, and generated docs consume this registry.
|
|
4
|
+
*/
|
|
5
|
+
export type ConfigKeyExposure = "public" | "maintainer" | "internal";
|
|
6
|
+
export type ConfigValueType = "string" | "boolean" | "number" | "object" | "array";
|
|
7
|
+
export type ConfigKeyMetadata = {
|
|
8
|
+
key: string;
|
|
9
|
+
type: ConfigValueType;
|
|
10
|
+
description: string;
|
|
11
|
+
default: unknown;
|
|
12
|
+
/** If set, value must equal one of these (after type coercion). */
|
|
13
|
+
allowedValues?: unknown[];
|
|
14
|
+
domainScope: "project" | "user" | "runtime" | "internal";
|
|
15
|
+
owningModule: string;
|
|
16
|
+
sensitive: boolean;
|
|
17
|
+
requiresRestart: boolean;
|
|
18
|
+
requiresApproval: boolean;
|
|
19
|
+
exposure: ConfigKeyExposure;
|
|
20
|
+
/** Persisted layers that may store this key */
|
|
21
|
+
writableLayers: ("project" | "user")[];
|
|
22
|
+
};
|
|
23
|
+
declare const REGISTRY: Record<string, ConfigKeyMetadata>;
|
|
24
|
+
export declare function getConfigKeyMetadata(key: string): ConfigKeyMetadata | undefined;
|
|
25
|
+
export declare function listConfigMetadata(options?: {
|
|
26
|
+
exposure?: "public" | "maintainer" | "internal" | "all";
|
|
27
|
+
}): ConfigKeyMetadata[];
|
|
28
|
+
export declare function assertWritableKey(key: string): ConfigKeyMetadata;
|
|
29
|
+
export declare function validateValueForMetadata(meta: ConfigKeyMetadata, value: unknown): void;
|
|
30
|
+
/**
|
|
31
|
+
* Validate top-level shape of persisted kit config files (strict unknown-key rejection).
|
|
32
|
+
*/
|
|
33
|
+
export declare function validatePersistedConfigDocument(data: Record<string, unknown>, label: string): void;
|
|
34
|
+
export declare function getConfigRegistryExport(): typeof REGISTRY;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical metadata for user-facing workspace config keys (Phase 2b).
|
|
3
|
+
* CLI, explain, and generated docs consume this registry.
|
|
4
|
+
*/
|
|
5
|
+
const REGISTRY = {
|
|
6
|
+
"tasks.storeRelativePath": {
|
|
7
|
+
key: "tasks.storeRelativePath",
|
|
8
|
+
type: "string",
|
|
9
|
+
description: "Relative path (from workspace root) to the task engine JSON state file.",
|
|
10
|
+
default: ".workspace-kit/tasks/state.json",
|
|
11
|
+
domainScope: "project",
|
|
12
|
+
owningModule: "task-engine",
|
|
13
|
+
sensitive: false,
|
|
14
|
+
requiresRestart: false,
|
|
15
|
+
requiresApproval: false,
|
|
16
|
+
exposure: "public",
|
|
17
|
+
writableLayers: ["project", "user"]
|
|
18
|
+
},
|
|
19
|
+
"policy.extraSensitiveModuleCommands": {
|
|
20
|
+
key: "policy.extraSensitiveModuleCommands",
|
|
21
|
+
type: "array",
|
|
22
|
+
description: "Additional module command names (e.g. run subcommands) treated as sensitive for policy approval.",
|
|
23
|
+
default: [],
|
|
24
|
+
domainScope: "project",
|
|
25
|
+
owningModule: "workspace-kit",
|
|
26
|
+
sensitive: true,
|
|
27
|
+
requiresRestart: false,
|
|
28
|
+
requiresApproval: true,
|
|
29
|
+
exposure: "maintainer",
|
|
30
|
+
writableLayers: ["project"]
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
export function getConfigKeyMetadata(key) {
|
|
34
|
+
return REGISTRY[key];
|
|
35
|
+
}
|
|
36
|
+
export function listConfigMetadata(options) {
|
|
37
|
+
const exposure = options?.exposure ?? "public";
|
|
38
|
+
const order = ["public", "maintainer", "internal"];
|
|
39
|
+
const maxIdx = exposure === "all" ? 2 : order.indexOf(exposure);
|
|
40
|
+
const allowed = maxIdx < 0 ? new Set(["public"]) : new Set(order.slice(0, maxIdx + 1));
|
|
41
|
+
return Object.values(REGISTRY)
|
|
42
|
+
.filter((m) => allowed.has(m.exposure))
|
|
43
|
+
.sort((a, b) => a.key.localeCompare(b.key));
|
|
44
|
+
}
|
|
45
|
+
export function assertWritableKey(key) {
|
|
46
|
+
const meta = REGISTRY[key];
|
|
47
|
+
if (!meta) {
|
|
48
|
+
const err = new Error(`config-unknown-key: '${key}' is not a registered config key`);
|
|
49
|
+
err.code = "config-unknown-key";
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
if (meta.exposure === "internal") {
|
|
53
|
+
const err = new Error(`config-internal-key: '${key}' is internal and not user-writable`);
|
|
54
|
+
err.code = "config-internal-key";
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
return meta;
|
|
58
|
+
}
|
|
59
|
+
export function validateValueForMetadata(meta, value) {
|
|
60
|
+
if (meta.type === "array") {
|
|
61
|
+
if (!Array.isArray(value)) {
|
|
62
|
+
throw typeError(meta.key, "array", value);
|
|
63
|
+
}
|
|
64
|
+
if (meta.key === "policy.extraSensitiveModuleCommands") {
|
|
65
|
+
for (const item of value) {
|
|
66
|
+
if (typeof item !== "string" || item.trim().length === 0) {
|
|
67
|
+
throw new Error(`config-type-error(${meta.key}): array entries must be non-empty strings`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (meta.type === "string" && typeof value !== "string") {
|
|
74
|
+
throw typeError(meta.key, "string", value);
|
|
75
|
+
}
|
|
76
|
+
if (meta.type === "boolean" && typeof value !== "boolean") {
|
|
77
|
+
throw typeError(meta.key, "boolean", value);
|
|
78
|
+
}
|
|
79
|
+
if (meta.type === "number" && typeof value !== "number") {
|
|
80
|
+
throw typeError(meta.key, "number", value);
|
|
81
|
+
}
|
|
82
|
+
if (meta.type === "object") {
|
|
83
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
84
|
+
throw typeError(meta.key, "object", value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (meta.allowedValues && meta.allowedValues.length > 0) {
|
|
88
|
+
if (!meta.allowedValues.some((v) => deepEqualLoose(v, value))) {
|
|
89
|
+
throw new Error(`config-constraint(${meta.key}): value not in allowed set: ${JSON.stringify(meta.allowedValues)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function typeError(key, expected, got) {
|
|
94
|
+
return new Error(`config-type-error(${key}): expected ${expected}, got ${got === null ? "null" : typeof got}`);
|
|
95
|
+
}
|
|
96
|
+
function deepEqualLoose(a, b) {
|
|
97
|
+
if (a === b)
|
|
98
|
+
return true;
|
|
99
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Validate top-level shape of persisted kit config files (strict unknown-key rejection).
|
|
103
|
+
*/
|
|
104
|
+
export function validatePersistedConfigDocument(data, label) {
|
|
105
|
+
const allowed = new Set(["schemaVersion", "core", "tasks", "documentation", "policy", "modules"]);
|
|
106
|
+
for (const k of Object.keys(data)) {
|
|
107
|
+
if (!allowed.has(k)) {
|
|
108
|
+
throw new Error(`config-invalid(${label}): unknown top-level key '${k}'`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (data.schemaVersion !== undefined && typeof data.schemaVersion !== "number") {
|
|
112
|
+
throw new Error(`config-invalid(${label}): schemaVersion must be a number`);
|
|
113
|
+
}
|
|
114
|
+
const tasks = data.tasks;
|
|
115
|
+
if (tasks !== undefined) {
|
|
116
|
+
if (typeof tasks !== "object" || tasks === null || Array.isArray(tasks)) {
|
|
117
|
+
throw new Error(`config-invalid(${label}): tasks must be an object`);
|
|
118
|
+
}
|
|
119
|
+
const t = tasks;
|
|
120
|
+
for (const k of Object.keys(t)) {
|
|
121
|
+
if (k !== "storeRelativePath") {
|
|
122
|
+
throw new Error(`config-invalid(${label}): unknown tasks.${k}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const policy = data.policy;
|
|
127
|
+
if (policy !== undefined) {
|
|
128
|
+
if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
|
|
129
|
+
throw new Error(`config-invalid(${label}): policy must be an object`);
|
|
130
|
+
}
|
|
131
|
+
const p = policy;
|
|
132
|
+
for (const k of Object.keys(p)) {
|
|
133
|
+
if (k !== "extraSensitiveModuleCommands") {
|
|
134
|
+
throw new Error(`config-invalid(${label}): unknown policy.${k}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (p.extraSensitiveModuleCommands !== undefined) {
|
|
138
|
+
validateValueForMetadata(REGISTRY["policy.extraSensitiveModuleCommands"], p.extraSensitiveModuleCommands);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const core = data.core;
|
|
142
|
+
if (core !== undefined) {
|
|
143
|
+
if (typeof core !== "object" || core === null || Array.isArray(core) || Object.keys(core).length > 0) {
|
|
144
|
+
throw new Error(`config-invalid(${label}): core must be an empty object when present`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const doc = data.documentation;
|
|
148
|
+
if (doc !== undefined) {
|
|
149
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc) || Object.keys(doc).length > 0) {
|
|
150
|
+
throw new Error(`config-invalid(${label}): documentation must be an empty object when present`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const mods = data.modules;
|
|
154
|
+
if (mods !== undefined) {
|
|
155
|
+
if (typeof mods !== "object" || mods === null || Array.isArray(mods) || Object.keys(mods).length > 0) {
|
|
156
|
+
throw new Error(`config-invalid(${label}): modules must be absent or an empty object`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function getConfigRegistryExport() {
|
|
161
|
+
return REGISTRY;
|
|
162
|
+
}
|