@principles/pd-cli 1.75.0 → 1.76.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/commands/config-doctor.d.ts +3 -6
- package/dist/commands/config-doctor.d.ts.map +1 -1
- package/dist/commands/config-doctor.js +30 -31
- package/dist/commands/config-doctor.js.map +1 -1
- package/dist/commands/runtime-features.d.ts +23 -8
- package/dist/commands/runtime-features.d.ts.map +1 -1
- package/dist/commands/runtime-features.js +72 -31
- package/dist/commands/runtime-features.js.map +1 -1
- package/dist/services/config-doctor.d.ts +26 -66
- package/dist/services/config-doctor.d.ts.map +1 -1
- package/dist/services/config-doctor.js +197 -374
- package/dist/services/config-doctor.js.map +1 -1
- package/dist/services/pd-config-loader.d.ts +64 -0
- package/dist/services/pd-config-loader.d.ts.map +1 -0
- package/dist/services/pd-config-loader.js +156 -0
- package/dist/services/pd-config-loader.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/config-doctor.ts +30 -30
- package/src/commands/runtime-features.ts +98 -44
- package/src/services/config-doctor.ts +236 -425
- package/src/services/pd-config-loader.ts +213 -0
- package/tests/commands/config-doctor.test.ts +207 -506
- package/tests/commands/runtime-features.test.ts +220 -85
- package/tests/services/pd-config-loader.test.ts +479 -0
|
@@ -1,114 +1,249 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* runtime-features tests — PRI-305
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - JSON purity (single parseable JSON object)
|
|
6
|
+
* - Missing config → defaults with nextAction
|
|
7
|
+
* - Malformed config → fail loud with reason and nextAction
|
|
8
|
+
* - Effective flags from .pd/config.yaml
|
|
9
|
+
* - No secret output
|
|
10
|
+
* - CLI handler: --json stdout purity and exit code
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
14
|
import * as fs from 'node:fs';
|
|
3
15
|
import * as path from 'node:path';
|
|
4
16
|
import * as os from 'node:os';
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
17
|
+
import * as yaml from 'js-yaml';
|
|
18
|
+
import { buildRuntimeFeaturesStatus, handleRuntimeFeaturesStatus, type RuntimeFeaturesOutput } from '../../src/commands/runtime-features.js';
|
|
19
|
+
|
|
20
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function mkTmpDir(): string {
|
|
23
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-runtime-features-test-'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rmTmpDir(dir: string): void {
|
|
27
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeConfig(workspaceDir: string, content: string): void {
|
|
31
|
+
const configDir = path.join(workspaceDir, '.pd');
|
|
32
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeValidConfigYaml(): string {
|
|
37
|
+
return yaml.dump({
|
|
38
|
+
version: 1,
|
|
39
|
+
features: {
|
|
40
|
+
prompt: { category: 'core', enabled: true },
|
|
41
|
+
code_tool_hook: { category: 'core', enabled: true },
|
|
42
|
+
defer_archive: { category: 'core', enabled: true },
|
|
43
|
+
correction_observer: { category: 'quiet', enabled: false },
|
|
44
|
+
empathy_observer: { category: 'quiet', enabled: false },
|
|
45
|
+
},
|
|
46
|
+
runtimeProfiles: {
|
|
47
|
+
'openclaw.default': { type: 'openclaw', source: 'default' },
|
|
48
|
+
},
|
|
49
|
+
internalAgents: {
|
|
50
|
+
defaultRuntime: 'openclaw.default',
|
|
51
|
+
agents: {
|
|
52
|
+
diagnostician: { enabled: true },
|
|
53
|
+
dreamer: { enabled: true },
|
|
54
|
+
scribe: { enabled: true },
|
|
55
|
+
artificer: { enabled: true },
|
|
56
|
+
philosopher: { enabled: false },
|
|
57
|
+
evaluator: { enabled: false },
|
|
58
|
+
rolloutReviewer: { enabled: false },
|
|
59
|
+
trainer: { enabled: false },
|
|
60
|
+
correctionObserver: { enabled: false },
|
|
61
|
+
empathyObserver: { enabled: false },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
ui: { diagnostics: { mode: 'simple' } },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── JSON purity ──────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe('JSON purity', () => {
|
|
71
|
+
it('output is a single parseable JSON object', () => {
|
|
72
|
+
const tmp = mkTmpDir();
|
|
73
|
+
try {
|
|
74
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
75
|
+
const json = JSON.stringify(output, null, 2);
|
|
76
|
+
const parsed = JSON.parse(json);
|
|
77
|
+
expect(typeof parsed).toBe('object');
|
|
78
|
+
expect(parsed).not.toBeNull();
|
|
79
|
+
expect(Array.isArray(parsed)).toBe(false);
|
|
80
|
+
} finally { rmTmpDir(tmp); }
|
|
81
|
+
});
|
|
82
|
+
});
|
|
7
83
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
84
|
+
// ── Missing config → defaults ────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('Missing config → defaults', () => {
|
|
87
|
+
it('returns status=ok with source=defaults when config file is absent', () => {
|
|
88
|
+
const tmp = mkTmpDir();
|
|
11
89
|
try {
|
|
12
|
-
const output =
|
|
90
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
13
91
|
expect(output.status).toBe('ok');
|
|
14
|
-
expect(output.
|
|
15
|
-
expect(output.
|
|
16
|
-
expect(output.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
92
|
+
expect(output.source).toBe('defaults');
|
|
93
|
+
expect(output.enabledMvpChannels).toContain('prompt');
|
|
94
|
+
expect(output.enabledMvpChannels).toContain('code_tool_hook');
|
|
95
|
+
expect(output.enabledMvpChannels).toContain('defer_archive');
|
|
96
|
+
} finally { rmTmpDir(tmp); }
|
|
20
97
|
});
|
|
98
|
+
});
|
|
21
99
|
|
|
22
|
-
|
|
23
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
|
|
24
|
-
const configDir = path.join(tmpDir, '.pd');
|
|
25
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
26
|
-
fs.writeFileSync(path.join(configDir, 'feature-flags.yaml'), 'gfi:\n enabled: "yes"\n', 'utf8');
|
|
100
|
+
// ── Malformed config → fail loud ────────────────────────────────────────────
|
|
27
101
|
|
|
102
|
+
describe('Malformed config → fail loud', () => {
|
|
103
|
+
it('returns status=failed with reason and nextAction for YAML parse error', () => {
|
|
104
|
+
const tmp = mkTmpDir();
|
|
105
|
+
writeConfig(tmp, 'version: [unterminated');
|
|
28
106
|
try {
|
|
29
|
-
const output =
|
|
30
|
-
expect(output.status).toBe('
|
|
31
|
-
expect(output.
|
|
32
|
-
expect(output.
|
|
33
|
-
expect(output.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
107
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
108
|
+
expect(output.status).toBe('failed');
|
|
109
|
+
expect(output.source).toBe('malformed');
|
|
110
|
+
expect(output.reason).toBeTruthy();
|
|
111
|
+
expect(output.nextAction).toBeTruthy();
|
|
112
|
+
expect(output.errors).toBeDefined();
|
|
113
|
+
expect(output.errors!.length).toBeGreaterThan(0);
|
|
114
|
+
} finally { rmTmpDir(tmp); }
|
|
37
115
|
});
|
|
38
116
|
|
|
39
|
-
it('
|
|
40
|
-
const
|
|
117
|
+
it('returns status=failed for invalid version', () => {
|
|
118
|
+
const tmp = mkTmpDir();
|
|
119
|
+
writeConfig(tmp, yaml.dump({ version: 99, features: {}, runtimeProfiles: {}, internalAgents: { defaultRuntime: 'x', agents: {} } }));
|
|
41
120
|
try {
|
|
42
|
-
const output =
|
|
43
|
-
|
|
44
|
-
|
|
121
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
122
|
+
expect(output.status).toBe('failed');
|
|
123
|
+
expect(output.source).toBe('malformed');
|
|
124
|
+
} finally { rmTmpDir(tmp); }
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ── Valid config → effective flags ───────────────────────────────────────────
|
|
45
129
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
expect(
|
|
53
|
-
expect(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
130
|
+
describe('Valid config → effective flags', () => {
|
|
131
|
+
it('returns status=ok with source=user_config for valid config', () => {
|
|
132
|
+
const tmp = mkTmpDir();
|
|
133
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
134
|
+
try {
|
|
135
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
136
|
+
expect(output.status).toBe('ok');
|
|
137
|
+
expect(output.source).toBe('user_config');
|
|
138
|
+
expect(output.enabledMvpChannels).toContain('prompt');
|
|
139
|
+
} finally { rmTmpDir(tmp); }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('shows all feature flags with category and enabled status', () => {
|
|
143
|
+
const tmp = mkTmpDir();
|
|
144
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
145
|
+
try {
|
|
146
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
147
|
+
expect(output.features.length).toBeGreaterThan(0);
|
|
148
|
+
const promptFlag = output.features.find(f => f.id === 'prompt');
|
|
149
|
+
expect(promptFlag).toBeDefined();
|
|
150
|
+
expect(promptFlag!.category).toBe('core');
|
|
151
|
+
expect(promptFlag!.enabled).toBe(true);
|
|
152
|
+
} finally { rmTmpDir(tmp); }
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('counts enabled and disabled correctly', () => {
|
|
156
|
+
const tmp = mkTmpDir();
|
|
157
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
158
|
+
try {
|
|
159
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
160
|
+
expect(output.totalFlags).toBeGreaterThan(0);
|
|
161
|
+
expect(output.enabledCount + output.disabledCount).toBe(output.totalFlags);
|
|
162
|
+
} finally { rmTmpDir(tmp); }
|
|
57
163
|
});
|
|
58
164
|
});
|
|
59
165
|
|
|
60
|
-
|
|
61
|
-
it('rejects __proto__ key in YAML', () => {
|
|
62
|
-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-features-test-'));
|
|
63
|
-
const configDir = path.join(tmpDir, '.pd');
|
|
64
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
65
|
-
fs.writeFileSync(
|
|
66
|
-
path.join(configDir, 'feature-flags.yaml'),
|
|
67
|
-
'"__proto__":\n enabled: true\n',
|
|
68
|
-
'utf8',
|
|
69
|
-
);
|
|
166
|
+
// ── No secret output ─────────────────────────────────────────────────────────
|
|
70
167
|
|
|
168
|
+
describe('No secret output', () => {
|
|
169
|
+
it('output never contains raw API key values', () => {
|
|
170
|
+
const tmp = mkTmpDir();
|
|
171
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
71
172
|
try {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
173
|
+
const output = buildRuntimeFeaturesStatus(tmp);
|
|
174
|
+
const json = JSON.stringify(output);
|
|
175
|
+
expect(json).not.toContain('sk-ant-');
|
|
176
|
+
expect(json).not.toContain('"apiKey"');
|
|
177
|
+
expect(json).not.toContain('"gatewayToken"');
|
|
178
|
+
} finally { rmTmpDir(tmp); }
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── CLI handler: --json stdout purity and exit code ──────────────────────────
|
|
183
|
+
|
|
184
|
+
describe('CLI handler: --json stdout purity and exit code', () => {
|
|
185
|
+
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
|
186
|
+
let originalExitCode: number | undefined;
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
190
|
+
originalExitCode = process.exitCode;
|
|
191
|
+
process.exitCode = undefined;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
afterEach(() => {
|
|
195
|
+
stdoutSpy.mockRestore();
|
|
196
|
+
process.exitCode = originalExitCode;
|
|
77
197
|
});
|
|
78
198
|
|
|
79
|
-
it('
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
199
|
+
it('--json outputs exactly one parseable JSON object to stdout', async () => {
|
|
200
|
+
const tmp = mkTmpDir();
|
|
201
|
+
try {
|
|
202
|
+
await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
|
|
203
|
+
expect(stdoutSpy).toHaveBeenCalledTimes(1);
|
|
204
|
+
const output = stdoutSpy.mock.calls[0][0] as string;
|
|
205
|
+
const parsed = JSON.parse(output);
|
|
206
|
+
expect(typeof parsed).toBe('object');
|
|
207
|
+
expect(parsed).not.toBeNull();
|
|
208
|
+
expect(Array.isArray(parsed)).toBe(false);
|
|
209
|
+
// Verify key fields exist
|
|
210
|
+
expect(parsed).toHaveProperty('status');
|
|
211
|
+
expect(parsed).toHaveProperty('source');
|
|
212
|
+
expect(parsed).toHaveProperty('features');
|
|
213
|
+
} finally { rmTmpDir(tmp); }
|
|
214
|
+
});
|
|
84
215
|
|
|
216
|
+
it('--json sets process.exitCode=1 on failed status', async () => {
|
|
217
|
+
const tmp = mkTmpDir();
|
|
218
|
+
writeConfig(tmp, 'version: [unterminated');
|
|
85
219
|
try {
|
|
86
|
-
|
|
87
|
-
expect(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
220
|
+
await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
|
|
221
|
+
expect(process.exitCode).toBe(1);
|
|
222
|
+
// Verify the JSON output still parses
|
|
223
|
+
const output = stdoutSpy.mock.calls[0][0] as string;
|
|
224
|
+
const parsed = JSON.parse(output);
|
|
225
|
+
expect(parsed.status).toBe('failed');
|
|
226
|
+
expect(parsed.reason).toBeTruthy();
|
|
227
|
+
expect(parsed.nextAction).toBeTruthy();
|
|
228
|
+
} finally { rmTmpDir(tmp); }
|
|
91
229
|
});
|
|
92
230
|
|
|
93
|
-
it('
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
'utf8',
|
|
101
|
-
);
|
|
231
|
+
it('--json does not set exitCode=1 on ok status', async () => {
|
|
232
|
+
const tmp = mkTmpDir();
|
|
233
|
+
try {
|
|
234
|
+
await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
|
|
235
|
+
expect(process.exitCode).toBeUndefined();
|
|
236
|
+
} finally { rmTmpDir(tmp); }
|
|
237
|
+
});
|
|
102
238
|
|
|
239
|
+
it('--json output contains no extra stdout lines (no banners, headers)', async () => {
|
|
240
|
+
const tmp = mkTmpDir();
|
|
103
241
|
try {
|
|
104
|
-
|
|
105
|
-
expect(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
} finally {
|
|
111
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
112
|
-
}
|
|
242
|
+
await handleRuntimeFeaturesStatus({ workspace: tmp, json: true });
|
|
243
|
+
expect(stdoutSpy).toHaveBeenCalledTimes(1);
|
|
244
|
+
const output = stdoutSpy.mock.calls[0][0] as string;
|
|
245
|
+
// Must parse as single JSON object — no leading/trailing non-JSON
|
|
246
|
+
expect(() => JSON.parse(output)).not.toThrow();
|
|
247
|
+
} finally { rmTmpDir(tmp); }
|
|
113
248
|
});
|
|
114
249
|
});
|