@principles/pd-cli 1.75.0 → 1.77.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/console-launcher-edge-cases.test.ts +421 -0
- package/tests/commands/runtime-features.test.ts +220 -85
- package/tests/services/pd-config-loader.test.ts +479 -0
|
@@ -1,624 +1,325 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* config-doctor tests — PRI-305
|
|
3
3
|
*
|
|
4
4
|
* Covers:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
5
|
+
* - Internal agent runtime binding readiness
|
|
6
|
+
* - JSON purity
|
|
7
|
+
* - Missing config → defaults
|
|
8
|
+
* - Malformed config → fail loud
|
|
9
|
+
* - No secret output
|
|
10
|
+
* - Legacy file detection
|
|
11
|
+
* - CLI handler: --json stdout purity and exit code
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import { describe, it, expect, beforeEach, afterEach
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
14
15
|
import * as fs from 'node:fs';
|
|
15
16
|
import * as path from 'node:path';
|
|
16
17
|
import * as os from 'node:os';
|
|
17
18
|
import * as yaml from 'js-yaml';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
19
|
+
import { buildDoctorOutput, type DoctorOutput } from '../../src/services/config-doctor.js';
|
|
20
|
+
import { handleConfigDoctor } from '../../src/commands/config-doctor.js';
|
|
20
21
|
|
|
21
|
-
//
|
|
22
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
23
24
|
function mkTmpDir(): string {
|
|
24
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-doctor-test-'));
|
|
25
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-config-doctor-test-'));
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function rmTmpDir(dir: string): void {
|
|
28
29
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
fs.writeFileSync(path.join(
|
|
32
|
+
function writeConfig(workspaceDir: string, content: string): void {
|
|
33
|
+
const configDir = path.join(workspaceDir, '.pd');
|
|
34
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
35
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), content, 'utf8');
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function
|
|
38
|
-
return {
|
|
39
|
-
version:
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
function makeValidConfigYaml(): string {
|
|
39
|
+
return yaml.dump({
|
|
40
|
+
version: 1,
|
|
41
|
+
features: {
|
|
42
|
+
prompt: { category: 'core', enabled: true },
|
|
43
|
+
code_tool_hook: { category: 'core', enabled: true },
|
|
44
|
+
defer_archive: { category: 'core', enabled: true },
|
|
45
|
+
correction_observer: { category: 'quiet', enabled: false },
|
|
46
|
+
empathy_observer: { category: 'quiet', enabled: false },
|
|
47
|
+
},
|
|
48
|
+
runtimeProfiles: {
|
|
49
|
+
'openclaw.default': { type: 'openclaw', source: 'default' },
|
|
50
|
+
'openclaw.model.lmstudio.qwen3': { type: 'openclaw', provider: 'lmstudio', model: 'qwen3.6-27b-mtp' },
|
|
51
|
+
'pd.anthropic-sonnet': { type: 'pi-ai', provider: 'anthropic', model: 'claude-3-5-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY', timeoutMs: 300000 },
|
|
52
|
+
},
|
|
53
|
+
internalAgents: {
|
|
54
|
+
defaultRuntime: 'openclaw.default',
|
|
55
|
+
agents: {
|
|
56
|
+
diagnostician: { enabled: true, runtimeProfile: 'openclaw.model.lmstudio.qwen3' },
|
|
57
|
+
dreamer: { enabled: true },
|
|
58
|
+
scribe: { enabled: true },
|
|
59
|
+
artificer: { enabled: true },
|
|
60
|
+
philosopher: { enabled: false },
|
|
61
|
+
evaluator: { enabled: false },
|
|
62
|
+
rolloutReviewer: { enabled: false },
|
|
63
|
+
trainer: { enabled: false },
|
|
64
|
+
correctionObserver: { enabled: false },
|
|
65
|
+
empathyObserver: { enabled: false },
|
|
51
66
|
},
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ─── resolveProviderConfigFromWorkflows ──────────────────────────────────────
|
|
57
|
-
|
|
58
|
-
describe('resolveProviderConfigFromWorkflows', () => {
|
|
59
|
-
it('returns source=missing when workflows.yaml is absent and no CLI overrides', async () => {
|
|
60
|
-
const tmp = mkTmpDir();
|
|
61
|
-
try {
|
|
62
|
-
const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
|
|
63
|
-
expect(result.source).toBe('missing');
|
|
64
|
-
expect(result.workflowsFound).toBe(false);
|
|
65
|
-
expect(result.provider).toBeNull();
|
|
66
|
-
expect(result.model).toBeNull();
|
|
67
|
-
expect(result.apiKeyEnv).toBeNull();
|
|
68
|
-
} finally { rmTmpDir(tmp); }
|
|
67
|
+
},
|
|
68
|
+
ui: { diagnostics: { mode: 'simple' } },
|
|
69
69
|
});
|
|
70
|
+
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
const tmp = mkTmpDir();
|
|
73
|
-
try {
|
|
74
|
-
const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'), {
|
|
75
|
-
cliProvider: 'openrouter',
|
|
76
|
-
cliModel: 'anthropic/claude-sonnet-4',
|
|
77
|
-
cliApiKeyEnv: 'OPENROUTER_API_KEY',
|
|
78
|
-
});
|
|
79
|
-
expect(result.source).toBe('cli_flag');
|
|
80
|
-
expect(result.workflowsFound).toBe(false);
|
|
81
|
-
expect(result.provider).toBe('openrouter');
|
|
82
|
-
expect(result.model).toBe('anthropic/claude-sonnet-4');
|
|
83
|
-
expect(result.apiKeyEnv).toBe('OPENROUTER_API_KEY');
|
|
84
|
-
} finally { rmTmpDir(tmp); }
|
|
85
|
-
});
|
|
72
|
+
// ── Internal agent runtime binding readiness ─────────────────────────────────
|
|
86
73
|
|
|
87
|
-
|
|
74
|
+
describe('Internal agent runtime binding readiness', () => {
|
|
75
|
+
it('disabled agents show readiness=disabled', async () => {
|
|
88
76
|
const tmp = mkTmpDir();
|
|
77
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
89
78
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
|
|
96
|
-
expect(result.source).toBe('workflows.yaml');
|
|
97
|
-
expect(result.workflowsFound).toBe(true);
|
|
98
|
-
expect(result.parseWarning).toBeUndefined();
|
|
99
|
-
expect(result.provider).toBe('openrouter');
|
|
100
|
-
expect(result.model).toBe('anthropic/claude-sonnet-4');
|
|
101
|
-
expect(result.apiKeyEnv).toBe('OPENROUTER_API_KEY');
|
|
79
|
+
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
80
|
+
const philosopher = output.internalAgents.find(a => a.name === 'philosopher');
|
|
81
|
+
expect(philosopher).toBeDefined();
|
|
82
|
+
expect(philosopher!.readiness).toBe('disabled');
|
|
83
|
+
expect(philosopher!.enabled).toBe(false);
|
|
102
84
|
} finally { rmTmpDir(tmp); }
|
|
103
85
|
});
|
|
104
86
|
|
|
105
|
-
it('
|
|
87
|
+
it('enabled agent with openclaw profile shows readiness=ready', async () => {
|
|
106
88
|
const tmp = mkTmpDir();
|
|
89
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
107
90
|
try {
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
expect(
|
|
111
|
-
expect(
|
|
112
|
-
expect(
|
|
113
|
-
expect(result.provider).toBeNull();
|
|
91
|
+
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
92
|
+
const diagnostician = output.internalAgents.find(a => a.name === 'diagnostician');
|
|
93
|
+
expect(diagnostician).toBeDefined();
|
|
94
|
+
expect(diagnostician!.enabled).toBe(true);
|
|
95
|
+
expect(diagnostician!.readiness).toBe('ready');
|
|
114
96
|
} finally { rmTmpDir(tmp); }
|
|
115
97
|
});
|
|
116
98
|
|
|
117
|
-
it('
|
|
99
|
+
it('enabled agent with pi-ai profile and missing env shows readiness=needs_setup', async () => {
|
|
118
100
|
const tmp = mkTmpDir();
|
|
101
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
102
|
+
// Ensure ANTHROPIC_API_KEY is not set
|
|
103
|
+
const originalKey = process.env.ANTHROPIC_API_KEY;
|
|
104
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
119
105
|
try {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
106
|
+
// Create config with an enabled agent using pi-ai profile
|
|
107
|
+
const config = yaml.dump({
|
|
108
|
+
version: 1,
|
|
109
|
+
features: {
|
|
110
|
+
prompt: { category: 'core', enabled: true },
|
|
111
|
+
code_tool_hook: { category: 'core', enabled: true },
|
|
112
|
+
defer_archive: { category: 'core', enabled: true },
|
|
113
|
+
correction_observer: { category: 'quiet', enabled: false },
|
|
114
|
+
empathy_observer: { category: 'quiet', enabled: false },
|
|
115
|
+
},
|
|
116
|
+
runtimeProfiles: {
|
|
117
|
+
'openclaw.default': { type: 'openclaw', source: 'default' },
|
|
118
|
+
'pd.anthropic-sonnet': { type: 'pi-ai', provider: 'anthropic', model: 'claude-3-5-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
|
119
|
+
},
|
|
120
|
+
internalAgents: {
|
|
121
|
+
defaultRuntime: 'openclaw.default',
|
|
122
|
+
agents: {
|
|
123
|
+
diagnostician: { enabled: true, runtimeProfile: 'pd.anthropic-sonnet' },
|
|
124
|
+
dreamer: { enabled: true },
|
|
125
|
+
scribe: { enabled: true },
|
|
126
|
+
artificer: { enabled: true },
|
|
127
|
+
philosopher: { enabled: false },
|
|
128
|
+
evaluator: { enabled: false },
|
|
129
|
+
rolloutReviewer: { enabled: false },
|
|
130
|
+
trainer: { enabled: false },
|
|
131
|
+
correctionObserver: { enabled: false },
|
|
132
|
+
empathyObserver: { enabled: false },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
123
135
|
});
|
|
124
|
-
|
|
125
|
-
expect(result.workflowsFound).toBe(true);
|
|
126
|
-
expect(result.parseWarning).toMatch(/funnel 'pd-runtime-v2-diagnosis' not found/);
|
|
127
|
-
} finally { rmTmpDir(tmp); }
|
|
128
|
-
});
|
|
136
|
+
writeConfig(tmp, config);
|
|
129
137
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
expect(
|
|
136
|
-
expect(
|
|
137
|
-
} finally {
|
|
138
|
+
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
139
|
+
const diagnostician = output.internalAgents.find(a => a.name === 'diagnostician');
|
|
140
|
+
expect(diagnostician).toBeDefined();
|
|
141
|
+
expect(diagnostician!.readiness).toBe('needs_setup');
|
|
142
|
+
expect(diagnostician!.apiKeyEnv).toBe('ANTHROPIC_API_KEY');
|
|
143
|
+
expect(diagnostician!.apiKeyPresent).toBe(false);
|
|
144
|
+
expect(diagnostician!.nextAction).toContain('ANTHROPIC_API_KEY');
|
|
145
|
+
} finally {
|
|
146
|
+
if (originalKey !== undefined) process.env.ANTHROPIC_API_KEY = originalKey;
|
|
147
|
+
rmTmpDir(tmp);
|
|
148
|
+
}
|
|
138
149
|
});
|
|
139
150
|
});
|
|
140
151
|
|
|
141
|
-
//
|
|
152
|
+
// ── JSON purity ──────────────────────────────────────────────────────────────
|
|
142
153
|
|
|
143
|
-
describe('
|
|
144
|
-
it('
|
|
154
|
+
describe('JSON purity', () => {
|
|
155
|
+
it('output is a single parseable JSON object', async () => {
|
|
145
156
|
const tmp = mkTmpDir();
|
|
146
157
|
try {
|
|
147
158
|
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
expect(
|
|
151
|
-
expect(
|
|
152
|
-
|
|
153
|
-
expect(ph.classification).toBe('config_missing');
|
|
154
|
-
expect(ph.provider).toBeNull();
|
|
155
|
-
expect(ph.model).toBeNull();
|
|
156
|
-
expect(ph.apiKeyPresent).toBe(false);
|
|
157
|
-
expect(ph.reason).toBeDefined();
|
|
158
|
-
expect(ph.nextAction).toBeDefined();
|
|
159
|
-
expect(output.nextActions.length).toBeGreaterThan(0);
|
|
159
|
+
const json = JSON.stringify(output, null, 2);
|
|
160
|
+
const parsed = JSON.parse(json);
|
|
161
|
+
expect(typeof parsed).toBe('object');
|
|
162
|
+
expect(parsed).not.toBeNull();
|
|
163
|
+
expect(Array.isArray(parsed)).toBe(false);
|
|
160
164
|
} finally { rmTmpDir(tmp); }
|
|
161
165
|
});
|
|
162
166
|
});
|
|
163
167
|
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
// ── Missing config → defaults ────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe('Missing config → defaults', () => {
|
|
171
|
+
it('returns status=ok when config file is absent (uses defaults)', async () => {
|
|
166
172
|
const tmp = mkTmpDir();
|
|
167
173
|
try {
|
|
168
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
|
|
169
|
-
provider: 'openrouter',
|
|
170
|
-
model: 'anthropic/claude-sonnet-4',
|
|
171
|
-
apiKeyEnv: 'PD_DOCTOR_TEST_KEY_NEVER_SET_X9Z',
|
|
172
|
-
}));
|
|
173
174
|
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
174
|
-
|
|
175
|
-
expect(
|
|
176
|
-
|
|
177
|
-
expect(
|
|
178
|
-
expect(ph.provider).toBe('openrouter');
|
|
179
|
-
expect(ph.model).toBe('anthropic/claude-sonnet-4');
|
|
180
|
-
expect(ph.apiKeyEnv).toBe('PD_DOCTOR_TEST_KEY_NEVER_SET_X9Z');
|
|
181
|
-
expect(ph.apiKeyPresent).toBe(false);
|
|
182
|
-
expect(ph.reason).toMatch(/not set or empty/);
|
|
175
|
+
// With defaults, MVP core features are enabled, so status should be ok or degraded
|
|
176
|
+
expect(['ok', 'degraded']).toContain(output.status);
|
|
177
|
+
expect(output.featureFlags.source).toBe('defaults');
|
|
178
|
+
expect(output.featureFlags.enabledMvpChannels).toContain('prompt');
|
|
183
179
|
} finally { rmTmpDir(tmp); }
|
|
184
180
|
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── Malformed config → fail loud ────────────────────────────────────────────
|
|
185
184
|
|
|
186
|
-
|
|
185
|
+
describe('Malformed config → fail loud', () => {
|
|
186
|
+
it('returns status=failed with reason and nextActions for YAML parse error', async () => {
|
|
187
187
|
const tmp = mkTmpDir();
|
|
188
|
+
writeConfig(tmp, 'version: [unterminated');
|
|
188
189
|
try {
|
|
189
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
|
|
190
|
-
provider: 'openrouter',
|
|
191
|
-
model: 'anthropic/claude-sonnet-4',
|
|
192
|
-
}));
|
|
193
190
|
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
194
191
|
expect(output.status).toBe('failed');
|
|
195
|
-
|
|
196
|
-
expect(
|
|
197
|
-
expect(ph.apiKeyEnv).toBeNull();
|
|
198
|
-
expect(ph.apiKeyPresent).toBe(false);
|
|
192
|
+
expect(output.reason).toBeTruthy();
|
|
193
|
+
expect(output.nextActions.length).toBeGreaterThan(0);
|
|
199
194
|
} finally { rmTmpDir(tmp); }
|
|
200
195
|
});
|
|
201
196
|
});
|
|
202
197
|
|
|
203
|
-
|
|
204
|
-
it('classifies provider as healthy when config is valid and env var is set', async () => {
|
|
205
|
-
const tmp = mkTmpDir();
|
|
206
|
-
const envName = 'PD_DOCTOR_TEST_KEY_PRESENT_OK';
|
|
207
|
-
const previous = process.env[envName];
|
|
208
|
-
process.env[envName] = 'redacted-test-value-not-leaked';
|
|
209
|
-
try {
|
|
210
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
|
|
211
|
-
provider: 'openrouter',
|
|
212
|
-
model: 'anthropic/claude-sonnet-4',
|
|
213
|
-
apiKeyEnv: envName,
|
|
214
|
-
}));
|
|
215
|
-
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
216
|
-
expect(output.status).toBe('ok');
|
|
217
|
-
const ph = output.providerHealth[0];
|
|
218
|
-
expect(ph.classification).toBe('healthy');
|
|
219
|
-
expect(ph.apiKeyEnv).toBe(envName);
|
|
220
|
-
expect(ph.apiKeyPresent).toBe(true);
|
|
221
|
-
// Ensure the env var value is NOT leaked
|
|
222
|
-
const json = JSON.stringify(output);
|
|
223
|
-
expect(json).not.toContain('redacted-test-value-not-leaked');
|
|
224
|
-
} finally {
|
|
225
|
-
if (previous === undefined) {
|
|
226
|
-
delete process.env[envName];
|
|
227
|
-
} else {
|
|
228
|
-
process.env[envName] = previous;
|
|
229
|
-
}
|
|
230
|
-
rmTmpDir(tmp);
|
|
231
|
-
}
|
|
232
|
-
});
|
|
233
|
-
});
|
|
198
|
+
// ── No secret output ─────────────────────────────────────────────────────────
|
|
234
199
|
|
|
235
|
-
describe('
|
|
236
|
-
it('
|
|
200
|
+
describe('No secret output', () => {
|
|
201
|
+
it('output never contains raw API key values', async () => {
|
|
237
202
|
const tmp = mkTmpDir();
|
|
238
|
-
|
|
239
|
-
const previous = process.env[envName];
|
|
240
|
-
process.env[envName] = 'redacted-test-value';
|
|
203
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
241
204
|
try {
|
|
242
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
|
|
243
|
-
provider: 'openrouter',
|
|
244
|
-
model: 'anthropic/claude-sonnet-4',
|
|
245
|
-
apiKeyEnv: envName,
|
|
246
|
-
}));
|
|
247
|
-
// Plant a fake state.db that includes a 429 / rate_limit message
|
|
248
|
-
await plantStateDbWithMessage(tmp, "Error: 429 too many requests, rpm exhausted");
|
|
249
205
|
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
250
|
-
const
|
|
251
|
-
expect(
|
|
252
|
-
expect(
|
|
253
|
-
expect(
|
|
254
|
-
} finally {
|
|
255
|
-
if (previous === undefined) {
|
|
256
|
-
delete process.env[envName];
|
|
257
|
-
} else {
|
|
258
|
-
process.env[envName] = previous;
|
|
259
|
-
}
|
|
260
|
-
rmTmpDir(tmp);
|
|
261
|
-
}
|
|
206
|
+
const json = JSON.stringify(output);
|
|
207
|
+
expect(json).not.toContain('sk-ant-');
|
|
208
|
+
expect(json).not.toMatch(/"apiKey"\s*:/);
|
|
209
|
+
expect(json).not.toContain('"gatewayToken"');
|
|
210
|
+
} finally { rmTmpDir(tmp); }
|
|
262
211
|
});
|
|
263
212
|
|
|
264
|
-
it('
|
|
213
|
+
it('internal agents show apiKeyEnv name, not value', async () => {
|
|
265
214
|
const tmp = mkTmpDir();
|
|
266
|
-
|
|
267
|
-
const previous = process.env[envName];
|
|
268
|
-
process.env[envName] = 'redacted-test-value';
|
|
215
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
269
216
|
try {
|
|
270
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
|
|
271
|
-
provider: 'openrouter',
|
|
272
|
-
model: 'anthropic/claude-sonnet-4',
|
|
273
|
-
apiKeyEnv: envName,
|
|
274
|
-
}));
|
|
275
|
-
await plantStateDbWithMessage(tmp, "candidate_failed: rpm exhausted for current model");
|
|
276
217
|
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
} else {
|
|
283
|
-
process.env[envName] = previous;
|
|
218
|
+
for (const agent of output.internalAgents) {
|
|
219
|
+
if (agent.apiKeyEnv) {
|
|
220
|
+
// apiKeyEnv should be the env var name, not the key value
|
|
221
|
+
expect(agent.apiKeyEnv).toMatch(/^[A-Z_]+$/);
|
|
222
|
+
}
|
|
284
223
|
}
|
|
285
|
-
|
|
286
|
-
}
|
|
224
|
+
} finally { rmTmpDir(tmp); }
|
|
287
225
|
});
|
|
288
226
|
});
|
|
289
227
|
|
|
290
|
-
|
|
291
|
-
it('never includes the env var value in the output', async () => {
|
|
292
|
-
const tmp = mkTmpDir();
|
|
293
|
-
const envName = 'PD_DOCTOR_TEST_KEY_REDACTION';
|
|
294
|
-
const secret = 'sk-1234567890abcdef-secret-do-not-leak';
|
|
295
|
-
const previous = process.env[envName];
|
|
296
|
-
process.env[envName] = secret;
|
|
297
|
-
try {
|
|
298
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
|
|
299
|
-
provider: 'openrouter',
|
|
300
|
-
model: 'anthropic/claude-sonnet-4',
|
|
301
|
-
apiKeyEnv: envName,
|
|
302
|
-
}));
|
|
303
|
-
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
304
|
-
const json = JSON.stringify(output);
|
|
305
|
-
expect(json).not.toContain(secret);
|
|
306
|
-
// env var name is allowed to appear; raw value is not
|
|
307
|
-
expect(json).toContain(envName);
|
|
308
|
-
} finally {
|
|
309
|
-
if (previous === undefined) {
|
|
310
|
-
delete process.env[envName];
|
|
311
|
-
} else {
|
|
312
|
-
process.env[envName] = previous;
|
|
313
|
-
}
|
|
314
|
-
rmTmpDir(tmp);
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
});
|
|
228
|
+
// ── Legacy file detection ────────────────────────────────────────────────────
|
|
318
229
|
|
|
319
|
-
describe('
|
|
320
|
-
it('
|
|
230
|
+
describe('Legacy file detection', () => {
|
|
231
|
+
it('detects .pd/feature-flags.yaml and reports as legacy', async () => {
|
|
321
232
|
const tmp = mkTmpDir();
|
|
233
|
+
const pdDir = path.join(tmp, '.pd');
|
|
234
|
+
fs.mkdirSync(pdDir, { recursive: true });
|
|
235
|
+
fs.writeFileSync(path.join(pdDir, 'feature-flags.yaml'), 'prompt:\n enabled: true\n', 'utf8');
|
|
322
236
|
try {
|
|
323
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), 'gfi: [unterminated');
|
|
324
237
|
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
325
|
-
expect(output.
|
|
326
|
-
expect(output.
|
|
327
|
-
expect(output.nextActions.some((a) => /workflows.yaml/i.test(a))).toBe(true);
|
|
238
|
+
expect(output.legacyFilesDetected.length).toBeGreaterThan(0);
|
|
239
|
+
expect(output.legacyFilesDetected[0]).toContain('feature-flags.yaml');
|
|
328
240
|
} finally { rmTmpDir(tmp); }
|
|
329
241
|
});
|
|
330
242
|
});
|
|
331
243
|
|
|
332
|
-
|
|
333
|
-
|
|
244
|
+
// ── Feature flags from config.yaml ───────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe('Feature flags from config.yaml', () => {
|
|
247
|
+
it('shows enabled MVP channels from config', async () => {
|
|
334
248
|
const tmp = mkTmpDir();
|
|
249
|
+
writeConfig(tmp, makeValidConfigYaml());
|
|
335
250
|
try {
|
|
336
|
-
const pdDir = path.join(tmp, '.pd');
|
|
337
|
-
fs.mkdirSync(pdDir, { recursive: true });
|
|
338
|
-
fs.writeFileSync(
|
|
339
|
-
path.join(pdDir, 'feature-flags.yaml'),
|
|
340
|
-
'prompt:\n enabled: true\ncode_tool_hook:\n enabled: true\ndefer_archive:\n enabled: true\ngfi:\n enabled: false\n',
|
|
341
|
-
'utf8',
|
|
342
|
-
);
|
|
343
251
|
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
344
|
-
expect(output.featureFlags.
|
|
345
|
-
expect(output.featureFlags.enabledMvpChannels).
|
|
346
|
-
|
|
347
|
-
);
|
|
348
|
-
expect(output.featureFlags.disabledFlags).toContain('gfi');
|
|
252
|
+
expect(output.featureFlags.enabledMvpChannels).toContain('prompt');
|
|
253
|
+
expect(output.featureFlags.enabledMvpChannels).toContain('code_tool_hook');
|
|
254
|
+
expect(output.featureFlags.enabledMvpChannels).toContain('defer_archive');
|
|
349
255
|
} finally { rmTmpDir(tmp); }
|
|
350
256
|
});
|
|
351
257
|
});
|
|
352
258
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
259
|
+
// ── CLI handler: --json stdout purity and exit code ──────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('CLI handler: --json stdout purity and exit code', () => {
|
|
262
|
+
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
|
263
|
+
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
|
264
|
+
let originalExitCode: number | undefined;
|
|
265
|
+
|
|
266
|
+
beforeEach(() => {
|
|
267
|
+
stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
268
|
+
stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
269
|
+
originalExitCode = process.exitCode;
|
|
270
|
+
process.exitCode = undefined;
|
|
365
271
|
});
|
|
366
|
-
});
|
|
367
272
|
|
|
368
|
-
|
|
273
|
+
afterEach(() => {
|
|
274
|
+
stdoutSpy.mockRestore();
|
|
275
|
+
stderrSpy.mockRestore();
|
|
276
|
+
process.exitCode = originalExitCode;
|
|
277
|
+
});
|
|
369
278
|
|
|
370
|
-
|
|
371
|
-
it('JSON.stringify produces a single parseable object containing all required fields', async () => {
|
|
279
|
+
it('--json outputs exactly one parseable JSON object to stdout', async () => {
|
|
372
280
|
const tmp = mkTmpDir();
|
|
373
281
|
try {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
282
|
+
await handleConfigDoctor({ workspace: tmp, json: true });
|
|
283
|
+
expect(stdoutSpy).toHaveBeenCalledTimes(1);
|
|
284
|
+
const output = stdoutSpy.mock.calls[0][0] as string;
|
|
285
|
+
const parsed = JSON.parse(output);
|
|
377
286
|
expect(typeof parsed).toBe('object');
|
|
378
287
|
expect(parsed).not.toBeNull();
|
|
379
|
-
expect(parsed).
|
|
380
|
-
// Required top-level fields
|
|
288
|
+
expect(Array.isArray(parsed)).toBe(false);
|
|
381
289
|
expect(parsed).toHaveProperty('status');
|
|
382
|
-
expect(parsed).toHaveProperty('workspaceDir');
|
|
383
|
-
expect(parsed).toHaveProperty('pdConfigPaths');
|
|
384
|
-
expect(parsed).toHaveProperty('openclawConfigPaths');
|
|
385
290
|
expect(parsed).toHaveProperty('featureFlags');
|
|
386
|
-
expect(parsed).toHaveProperty('
|
|
387
|
-
expect(parsed).toHaveProperty('warnings');
|
|
388
|
-
expect(parsed).toHaveProperty('nextActions');
|
|
389
|
-
// status is one of ok|degraded|failed
|
|
390
|
-
expect(['ok', 'degraded', 'failed']).toContain(parsed.status);
|
|
291
|
+
expect(parsed).toHaveProperty('internalAgents');
|
|
391
292
|
} finally { rmTmpDir(tmp); }
|
|
392
293
|
});
|
|
393
294
|
|
|
394
|
-
it('
|
|
395
|
-
const tmp = mkTmpDir();
|
|
396
|
-
const envName = 'PD_DOCTOR_TEST_JSON_REDACTION';
|
|
397
|
-
const secret = 'super-secret-value-do-not-leak-12345';
|
|
398
|
-
const previous = process.env[envName];
|
|
399
|
-
process.env[envName] = secret;
|
|
400
|
-
try {
|
|
401
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
|
|
402
|
-
provider: 'openrouter',
|
|
403
|
-
model: 'anthropic/claude-sonnet-4',
|
|
404
|
-
apiKeyEnv: envName,
|
|
405
|
-
}));
|
|
406
|
-
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
407
|
-
const json = JSON.stringify(output, null, 2);
|
|
408
|
-
expect(json).not.toContain(secret);
|
|
409
|
-
} finally {
|
|
410
|
-
if (previous === undefined) {
|
|
411
|
-
delete process.env[envName];
|
|
412
|
-
} else {
|
|
413
|
-
process.env[envName] = previous;
|
|
414
|
-
}
|
|
415
|
-
rmTmpDir(tmp);
|
|
416
|
-
}
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
describe('buildDoctorOutput — feature flags failure resilience', () => {
|
|
421
|
-
it('does not throw when feature-flags.yaml is a directory, outputs degraded status and flag warnings', async () => {
|
|
295
|
+
it('--json sets process.exitCode=1 on failed status', async () => {
|
|
422
296
|
const tmp = mkTmpDir();
|
|
297
|
+
writeConfig(tmp, 'version: [unterminated');
|
|
423
298
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
expect(
|
|
430
|
-
expect(output.featureFlags.source).toBe('unavailable');
|
|
431
|
-
expect(output.featureFlags.warnings.some(w => w.includes('feature flags unavailable'))).toBe(true);
|
|
432
|
-
expect(output.warnings.some(w => w.includes('feature flags unavailable'))).toBe(true);
|
|
433
|
-
expect(output.nextActions.some(a => a.includes('readable file, not a directory'))).toBe(true);
|
|
299
|
+
await handleConfigDoctor({ workspace: tmp, json: true });
|
|
300
|
+
expect(process.exitCode).toBe(1);
|
|
301
|
+
const output = stdoutSpy.mock.calls[0][0] as string;
|
|
302
|
+
const parsed = JSON.parse(output);
|
|
303
|
+
expect(parsed.status).toBe('failed');
|
|
304
|
+
expect(parsed.reason).toBeTruthy();
|
|
434
305
|
} finally { rmTmpDir(tmp); }
|
|
435
306
|
});
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
describe('buildDoctorOutput — CorrectionObserver diagnostics', () => {
|
|
439
|
-
it('reports auth_missing when ANTHROPIC_API_KEY is not set (default env fallback)', async () => {
|
|
440
|
-
const tmp = mkTmpDir();
|
|
441
|
-
const apiKeyEnv = 'ANTHROPIC_API_KEY';
|
|
442
|
-
const previous = process.env[apiKeyEnv];
|
|
443
|
-
delete process.env[apiKeyEnv];
|
|
444
|
-
try {
|
|
445
|
-
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
446
|
-
const co = output.internalAgents.correctionObserver;
|
|
447
|
-
expect(co.enabled).toBe(true);
|
|
448
|
-
expect(co.status).toBe('auth_missing');
|
|
449
|
-
expect(co.configSource).toBe('env');
|
|
450
|
-
expect(co.apiKeyEnv).toBe('ANTHROPIC_API_KEY');
|
|
451
|
-
expect(co.apiKeyPresent).toBe(false);
|
|
452
|
-
expect(co.nextAction).toMatch(/set the environment variable 'ANTHROPIC_API_KEY'/i);
|
|
453
|
-
} finally {
|
|
454
|
-
if (previous !== undefined) {
|
|
455
|
-
process.env[apiKeyEnv] = previous;
|
|
456
|
-
}
|
|
457
|
-
rmTmpDir(tmp);
|
|
458
|
-
}
|
|
459
|
-
});
|
|
460
307
|
|
|
461
|
-
it('
|
|
308
|
+
it('--json does not set exitCode=1 on ok status', async () => {
|
|
462
309
|
const tmp = mkTmpDir();
|
|
463
310
|
try {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
fs.writeFileSync(
|
|
467
|
-
path.join(pdDir, 'feature-flags.yaml'),
|
|
468
|
-
'correction_observer:\n enabled: false\n',
|
|
469
|
-
'utf8',
|
|
470
|
-
);
|
|
471
|
-
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
472
|
-
const co = output.internalAgents.correctionObserver;
|
|
473
|
-
expect(co.enabled).toBe(false);
|
|
474
|
-
expect(co.status).toBe('disabled');
|
|
475
|
-
expect(co.reason).toContain('CorrectionObserver is disabled');
|
|
476
|
-
expect(co.nextAction).toContain('correction_observer.enabled=true');
|
|
311
|
+
await handleConfigDoctor({ workspace: tmp, json: true });
|
|
312
|
+
expect(process.exitCode).toBeUndefined();
|
|
477
313
|
} finally { rmTmpDir(tmp); }
|
|
478
314
|
});
|
|
479
315
|
|
|
480
|
-
it('
|
|
481
|
-
const tmp = mkTmpDir();
|
|
482
|
-
const customKeyEnv = 'PD_DOCTOR_TEST_CO_KEY';
|
|
483
|
-
const previous = process.env[customKeyEnv];
|
|
484
|
-
process.env[customKeyEnv] = 'dummy-key-value-not-leaked';
|
|
485
|
-
try {
|
|
486
|
-
writeWorkflowsYaml(path.join(tmp, '.state'), {
|
|
487
|
-
version: '1',
|
|
488
|
-
funnels: [
|
|
489
|
-
{
|
|
490
|
-
workflowId: 'pd-correction-observer',
|
|
491
|
-
policy: {
|
|
492
|
-
runtimeKind: 'pi-ai',
|
|
493
|
-
provider: 'anthropic',
|
|
494
|
-
model: 'anthropic/claude-3-5-sonnet',
|
|
495
|
-
apiKeyEnv: customKeyEnv,
|
|
496
|
-
},
|
|
497
|
-
},
|
|
498
|
-
],
|
|
499
|
-
});
|
|
500
|
-
const output = await buildDoctorOutput({ workspaceDir: tmp });
|
|
501
|
-
const co = output.internalAgents.correctionObserver;
|
|
502
|
-
expect(co.enabled).toBe(true);
|
|
503
|
-
expect(co.status).toBe('configured');
|
|
504
|
-
expect(co.configSource).toBe('workflows.yaml');
|
|
505
|
-
expect(co.provider).toBe('anthropic');
|
|
506
|
-
expect(co.model).toBe('anthropic/claude-3-5-sonnet');
|
|
507
|
-
expect(co.apiKeyEnv).toBe(customKeyEnv);
|
|
508
|
-
expect(co.apiKeyPresent).toBe(true);
|
|
509
|
-
const json = JSON.stringify(output);
|
|
510
|
-
expect(json).not.toContain('dummy-key-value-not-leaked');
|
|
511
|
-
} finally {
|
|
512
|
-
if (previous === undefined) {
|
|
513
|
-
delete process.env[customKeyEnv];
|
|
514
|
-
} else {
|
|
515
|
-
process.env[customKeyEnv] = previous;
|
|
516
|
-
}
|
|
517
|
-
rmTmpDir(tmp);
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
// ─── CLI command wiring ──────────────────────────────────────────────────────
|
|
523
|
-
|
|
524
|
-
describe('CLI command wiring (pd config doctor)', () => {
|
|
525
|
-
let cliPath: string;
|
|
526
|
-
let workspaceRoot: string;
|
|
527
|
-
|
|
528
|
-
beforeEach(() => {
|
|
529
|
-
workspaceRoot = path.resolve(__dirname, '../../../..');
|
|
530
|
-
cliPath = path.join(workspaceRoot, 'packages', 'pd-cli', 'dist', 'index.js');
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it('config doctor is registered at pd config doctor --help', () => {
|
|
534
|
-
const out = runPd(['config', 'doctor', '--help'], workspaceRoot);
|
|
535
|
-
expect(out).toContain('PD');
|
|
536
|
-
expect(out).toContain('--workspace');
|
|
537
|
-
expect(out).toContain('--json');
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
it('config subcommand appears in pd --help', () => {
|
|
541
|
-
const out = runPd(['--help'], workspaceRoot);
|
|
542
|
-
expect(out).toMatch(/\bconfig\b/);
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
it('pd config doctor --json outputs a single parseable JSON object on stdout', () => {
|
|
316
|
+
it('--json output contains no extra stdout lines (no banners, headers)', async () => {
|
|
546
317
|
const tmp = mkTmpDir();
|
|
547
318
|
try {
|
|
548
|
-
|
|
549
|
-
expect(
|
|
550
|
-
const
|
|
551
|
-
expect(
|
|
552
|
-
expect(parsed).not.toBeNull();
|
|
553
|
-
expect(parsed).toHaveProperty('status');
|
|
554
|
-
expect(parsed).toHaveProperty('workspaceDir');
|
|
555
|
-
expect(parsed).toHaveProperty('pdConfigPaths');
|
|
556
|
-
expect(parsed).toHaveProperty('openclawConfigPaths');
|
|
557
|
-
expect(parsed).toHaveProperty('featureFlags');
|
|
558
|
-
expect(parsed).toHaveProperty('providerHealth');
|
|
559
|
-
expect(parsed).toHaveProperty('warnings');
|
|
560
|
-
expect(parsed).toHaveProperty('nextActions');
|
|
319
|
+
await handleConfigDoctor({ workspace: tmp, json: true });
|
|
320
|
+
expect(stdoutSpy).toHaveBeenCalledTimes(1);
|
|
321
|
+
const output = stdoutSpy.mock.calls[0][0] as string;
|
|
322
|
+
expect(() => JSON.parse(output)).not.toThrow();
|
|
561
323
|
} finally { rmTmpDir(tmp); }
|
|
562
324
|
});
|
|
563
|
-
|
|
564
|
-
it('pd config doctor --workspace <missing> still emits structured JSON (no crash)', () => {
|
|
565
|
-
const out = runPd(['config', 'doctor', '--workspace', '/nonexistent/workspace/path/x9z', '--json'], workspaceRoot);
|
|
566
|
-
const parsed = JSON.parse(out);
|
|
567
|
-
expect(parsed.status).toBe('failed');
|
|
568
|
-
expect(parsed.providerHealth).toBeInstanceOf(Array);
|
|
569
|
-
expect(parsed.nextActions).toBeInstanceOf(Array);
|
|
570
|
-
});
|
|
571
325
|
});
|
|
572
|
-
|
|
573
|
-
// ─── Helpers for CLI tests ───────────────────────────────────────────────────
|
|
574
|
-
|
|
575
|
-
function runPd(args: string[], cwd: string): string {
|
|
576
|
-
try {
|
|
577
|
-
return execFileSync('node', ['packages/pd-cli/dist/index.js', ...args], {
|
|
578
|
-
encoding: 'utf8',
|
|
579
|
-
cwd,
|
|
580
|
-
});
|
|
581
|
-
} catch (err: unknown) {
|
|
582
|
-
if (err && typeof err === 'object' && 'stdout' in err) {
|
|
583
|
-
return String((err as { stdout: unknown }).stdout);
|
|
584
|
-
}
|
|
585
|
-
throw err;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// ─── Helpers for state.db planting ───────────────────────────────────────────
|
|
590
|
-
|
|
591
|
-
async function plantStateDbWithMessage(workspaceDir: string, errorMessage: string): Promise<void> {
|
|
592
|
-
let Database: typeof import('better-sqlite3');
|
|
593
|
-
try {
|
|
594
|
-
Database = (await import('better-sqlite3')).default;
|
|
595
|
-
} catch {
|
|
596
|
-
// better-sqlite3 not available; tests requiring this will be skipped.
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
const pdDir = path.join(workspaceDir, '.pd');
|
|
600
|
-
fs.mkdirSync(pdDir, { recursive: true });
|
|
601
|
-
const dbPath = path.join(pdDir, 'state.db');
|
|
602
|
-
const db = new Database(dbPath);
|
|
603
|
-
try {
|
|
604
|
-
db.exec(`
|
|
605
|
-
CREATE TABLE IF NOT EXISTS tasks (
|
|
606
|
-
task_id TEXT,
|
|
607
|
-
kind TEXT,
|
|
608
|
-
status TEXT,
|
|
609
|
-
error_message TEXT,
|
|
610
|
-
error_category TEXT,
|
|
611
|
-
updated_at INTEGER
|
|
612
|
-
);
|
|
613
|
-
`);
|
|
614
|
-
const now = Date.now();
|
|
615
|
-
const insert = db.prepare(
|
|
616
|
-
`INSERT INTO tasks (task_id, kind, status, error_message, error_category, updated_at)
|
|
617
|
-
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
618
|
-
);
|
|
619
|
-
insert.run('test-task-1', 'diagnostician', 'failed', errorMessage, 'rate_limit', now);
|
|
620
|
-
insert.run('test-task-2', 'diagnostician', 'failed', 'previous unrelated error', 'other', now - 60_000);
|
|
621
|
-
} finally {
|
|
622
|
-
db.close();
|
|
623
|
-
}
|
|
624
|
-
}
|