@principles/pd-cli 1.73.1 → 1.74.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.
@@ -10,7 +10,7 @@ function runPdHelp(args: string[]): string {
10
10
  try {
11
11
  return execFileSync('node', ['packages/pd-cli/dist/index.js', ...args], {
12
12
  encoding: 'utf8',
13
- cwd: 'D:/Code/principles',
13
+ cwd: process.cwd(),
14
14
  });
15
15
  } catch (err: unknown) {
16
16
  if (err && typeof err === 'object' && 'stdout' in err) {
@@ -0,0 +1,624 @@
1
+ /**
2
+ * pd config doctor tests (PRI-299).
3
+ *
4
+ * Covers:
5
+ * - workspace + openclaw path discovery
6
+ * - feature-flag presence + warnings
7
+ * - provider classification: healthy / auth_missing / rate_limit / config_missing / parse_failure
8
+ * - JSON output is a single parseable object
9
+ * - secrets are never leaked
10
+ * - CLI command wiring (pd config doctor --help, --workspace, --json)
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import * as os from 'node:os';
17
+ import * as yaml from 'js-yaml';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { buildDoctorOutput, resolveProviderConfigFromWorkflows, getOpenClawHome, getOpenClawConfigPath } from '../../src/services/config-doctor.js';
20
+
21
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
22
+
23
+ function mkTmpDir(): string {
24
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-doctor-test-'));
25
+ }
26
+
27
+ function rmTmpDir(dir: string): void {
28
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
29
+ }
30
+
31
+ function writeWorkflowsYaml(stateDir: string, content: unknown): void {
32
+ fs.mkdirSync(stateDir, { recursive: true });
33
+ const yamlText = typeof content === 'string' ? content : yaml.dump(content);
34
+ fs.writeFileSync(path.join(stateDir, 'workflows.yaml'), yamlText, 'utf8');
35
+ }
36
+
37
+ function buildWorkflowsYaml(opts: { provider?: string; model?: string; apiKeyEnv?: string; baseUrl?: string }): unknown {
38
+ return {
39
+ version: '1',
40
+ funnels: [
41
+ {
42
+ workflowId: 'pd-runtime-v2-diagnosis',
43
+ stages: [],
44
+ policy: {
45
+ runtimeKind: 'pi-ai',
46
+ ...(opts.provider ? { provider: opts.provider } : {}),
47
+ ...(opts.model ? { model: opts.model } : {}),
48
+ ...(opts.apiKeyEnv ? { apiKeyEnv: opts.apiKeyEnv } : {}),
49
+ ...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
50
+ },
51
+ },
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); }
69
+ });
70
+
71
+ it('returns source=cli_flag when workflows.yaml is absent but CLI flags supplied', async () => {
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
+ });
86
+
87
+ it('parses a well-formed workflows.yaml with the diagnostic funnel', async () => {
88
+ const tmp = mkTmpDir();
89
+ try {
90
+ writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
91
+ provider: 'openrouter',
92
+ model: 'anthropic/claude-sonnet-4',
93
+ apiKeyEnv: 'OPENROUTER_API_KEY',
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');
102
+ } finally { rmTmpDir(tmp); }
103
+ });
104
+
105
+ it('returns parseWarning on YAML parse error', async () => {
106
+ const tmp = mkTmpDir();
107
+ try {
108
+ writeWorkflowsYaml(path.join(tmp, '.state'), 'gfi: [unterminated');
109
+ const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
110
+ expect(result.workflowsFound).toBe(true);
111
+ expect(result.parseWarning).toBeDefined();
112
+ expect(result.parseWarning).toMatch(/parse error/i);
113
+ expect(result.provider).toBeNull();
114
+ } finally { rmTmpDir(tmp); }
115
+ });
116
+
117
+ it('returns parseWarning when diagnostic funnel is missing', async () => {
118
+ const tmp = mkTmpDir();
119
+ try {
120
+ writeWorkflowsYaml(path.join(tmp, '.state'), {
121
+ version: '1',
122
+ funnels: [{ workflowId: 'some-other-funnel', stages: [], policy: {} }],
123
+ });
124
+ const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
125
+ expect(result.workflowsFound).toBe(true);
126
+ expect(result.parseWarning).toMatch(/funnel 'pd-runtime-v2-diagnosis' not found/);
127
+ } finally { rmTmpDir(tmp); }
128
+ });
129
+
130
+ it('returns parseWarning when funnels is not an array', async () => {
131
+ const tmp = mkTmpDir();
132
+ try {
133
+ writeWorkflowsYaml(path.join(tmp, '.state'), 'version: 1\nfunnels: not-an-array\n');
134
+ const result = await resolveProviderConfigFromWorkflows(path.join(tmp, '.state'));
135
+ expect(result.workflowsFound).toBe(true);
136
+ expect(result.parseWarning).toMatch(/funnels is not an array/);
137
+ } finally { rmTmpDir(tmp); }
138
+ });
139
+ });
140
+
141
+ // ─── buildDoctorOutput ───────────────────────────────────────────────────────
142
+
143
+ describe('buildDoctorOutput — config_missing', () => {
144
+ it('classifies provider as config_missing when workflows.yaml is absent and no CLI overrides', async () => {
145
+ const tmp = mkTmpDir();
146
+ try {
147
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
148
+ expect(output.status).toBe('failed');
149
+ expect(output.reason).toBeDefined();
150
+ expect(output.reason).toMatch(/auth_missing|config_missing/);
151
+ expect(output.providerHealth).toHaveLength(1);
152
+ const ph = output.providerHealth[0];
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);
160
+ } finally { rmTmpDir(tmp); }
161
+ });
162
+ });
163
+
164
+ describe('buildDoctorOutput — auth_missing', () => {
165
+ it('classifies provider as auth_missing when apiKeyEnv is set in workflows but env var is unset', async () => {
166
+ const tmp = mkTmpDir();
167
+ 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
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
174
+ expect(output.status).toBe('failed');
175
+ expect(output.reason).toMatch(/auth_missing|config_missing/);
176
+ const ph = output.providerHealth[0];
177
+ expect(ph.classification).toBe('auth_missing');
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/);
183
+ } finally { rmTmpDir(tmp); }
184
+ });
185
+
186
+ it('classifies provider as auth_missing when apiKeyEnv is not in workflows.yaml at all', async () => {
187
+ const tmp = mkTmpDir();
188
+ try {
189
+ writeWorkflowsYaml(path.join(tmp, '.state'), buildWorkflowsYaml({
190
+ provider: 'openrouter',
191
+ model: 'anthropic/claude-sonnet-4',
192
+ }));
193
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
194
+ expect(output.status).toBe('failed');
195
+ const ph = output.providerHealth[0];
196
+ expect(ph.classification).toBe('auth_missing');
197
+ expect(ph.apiKeyEnv).toBeNull();
198
+ expect(ph.apiKeyPresent).toBe(false);
199
+ } finally { rmTmpDir(tmp); }
200
+ });
201
+ });
202
+
203
+ describe('buildDoctorOutput — healthy', () => {
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
+ });
234
+
235
+ describe('buildDoctorOutput — rate_limit', () => {
236
+ it('classifies provider as rate_limit when state.db contains a 429 signature', async () => {
237
+ const tmp = mkTmpDir();
238
+ const envName = 'PD_DOCTOR_TEST_KEY_RATELIMIT_OK';
239
+ const previous = process.env[envName];
240
+ process.env[envName] = 'redacted-test-value';
241
+ 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
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
250
+ const ph = output.providerHealth[0];
251
+ expect(ph.classification).toBe('rate_limit');
252
+ expect(ph.reason).toMatch(/rate_limit|signature/i);
253
+ expect(output.status).toBe('degraded');
254
+ } finally {
255
+ if (previous === undefined) {
256
+ delete process.env[envName];
257
+ } else {
258
+ process.env[envName] = previous;
259
+ }
260
+ rmTmpDir(tmp);
261
+ }
262
+ });
263
+
264
+ it('classifies provider as rate_limit when state.db has candidate_failed + rpm exhausted', async () => {
265
+ const tmp = mkTmpDir();
266
+ const envName = 'PD_DOCTOR_TEST_KEY_RATELIMIT_2';
267
+ const previous = process.env[envName];
268
+ process.env[envName] = 'redacted-test-value';
269
+ 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
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
277
+ const ph = output.providerHealth[0];
278
+ expect(ph.classification).toBe('rate_limit');
279
+ } finally {
280
+ if (previous === undefined) {
281
+ delete process.env[envName];
282
+ } else {
283
+ process.env[envName] = previous;
284
+ }
285
+ rmTmpDir(tmp);
286
+ }
287
+ });
288
+ });
289
+
290
+ describe('buildDoctorOutput — secrets redaction', () => {
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
+ });
318
+
319
+ describe('buildDoctorOutput — parse_failure (workflows.yaml)', () => {
320
+ it('reports parse_failure warning when workflows.yaml is malformed', async () => {
321
+ const tmp = mkTmpDir();
322
+ try {
323
+ writeWorkflowsYaml(path.join(tmp, '.state'), 'gfi: [unterminated');
324
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
325
+ expect(output.warnings.length).toBeGreaterThan(0);
326
+ expect(output.warnings.some((w) => /parse error/i.test(w))).toBe(true);
327
+ expect(output.nextActions.some((a) => /workflows.yaml/i.test(a))).toBe(true);
328
+ } finally { rmTmpDir(tmp); }
329
+ });
330
+ });
331
+
332
+ describe('buildDoctorOutput — feature flags', () => {
333
+ it('reports enabled MVP channels from feature-flags.yaml', async () => {
334
+ const tmp = mkTmpDir();
335
+ 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
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
344
+ expect(output.featureFlags.source).toBe('workspace_file');
345
+ expect(output.featureFlags.enabledMvpChannels).toEqual(
346
+ expect.arrayContaining(['prompt', 'code_tool_hook', 'defer_archive']),
347
+ );
348
+ expect(output.featureFlags.disabledFlags).toContain('gfi');
349
+ } finally { rmTmpDir(tmp); }
350
+ });
351
+ });
352
+
353
+ describe('buildDoctorOutput — paths', () => {
354
+ it('reports existence of PD and OpenClaw config paths', async () => {
355
+ const tmp = mkTmpDir();
356
+ try {
357
+ fs.mkdirSync(path.join(tmp, '.pd'), { recursive: true });
358
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
359
+ expect(output.pdConfigPaths.workspaceDir.exists).toBe(true);
360
+ expect(output.pdConfigPaths.pdDir.exists).toBe(true);
361
+ expect(output.pdConfigPaths.featureFlags.exists).toBe(false);
362
+ expect(output.openclawConfigPaths.openclawHome.path).toBe(getOpenClawHome());
363
+ expect(output.openclawConfigPaths.openclawConfig.path).toBe(getOpenClawConfigPath());
364
+ } finally { rmTmpDir(tmp); }
365
+ });
366
+ });
367
+
368
+ // ─── JSON shape contract ─────────────────────────────────────────────────────
369
+
370
+ describe('buildDoctorOutput — JSON output contract', () => {
371
+ it('JSON.stringify produces a single parseable object containing all required fields', async () => {
372
+ const tmp = mkTmpDir();
373
+ try {
374
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
375
+ const json = JSON.stringify(output, null, 2);
376
+ const parsed: unknown = JSON.parse(json);
377
+ expect(typeof parsed).toBe('object');
378
+ expect(parsed).not.toBeNull();
379
+ expect(parsed).not.toBeInstanceOf(Array);
380
+ // Required top-level fields
381
+ expect(parsed).toHaveProperty('status');
382
+ expect(parsed).toHaveProperty('workspaceDir');
383
+ expect(parsed).toHaveProperty('pdConfigPaths');
384
+ expect(parsed).toHaveProperty('openclawConfigPaths');
385
+ expect(parsed).toHaveProperty('featureFlags');
386
+ expect(parsed).toHaveProperty('providerHealth');
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);
391
+ } finally { rmTmpDir(tmp); }
392
+ });
393
+
394
+ it('no env var value is present in the JSON output even when key is set', async () => {
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 () => {
422
+ const tmp = mkTmpDir();
423
+ try {
424
+ const pdDir = path.join(tmp, '.pd');
425
+ fs.mkdirSync(pdDir, { recursive: true });
426
+ fs.mkdirSync(path.join(pdDir, 'feature-flags.yaml'), { recursive: true });
427
+
428
+ const output = await buildDoctorOutput({ workspaceDir: tmp });
429
+ expect(['degraded', 'failed']).toContain(output.status);
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);
434
+ } finally { rmTmpDir(tmp); }
435
+ });
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
+
461
+ it('reports disabled when correction_observer is disabled in feature-flags.yaml', async () => {
462
+ const tmp = mkTmpDir();
463
+ try {
464
+ const pdDir = path.join(tmp, '.pd');
465
+ fs.mkdirSync(pdDir, { recursive: true });
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');
477
+ } finally { rmTmpDir(tmp); }
478
+ });
479
+
480
+ it('reports configured when pd-correction-observer policy is present in workflows.yaml and key exists', async () => {
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', () => {
546
+ const tmp = mkTmpDir();
547
+ try {
548
+ const out = runPd(['config', 'doctor', '--workspace', tmp, '--json'], workspaceRoot);
549
+ expect(out).toBeDefined();
550
+ const parsed = JSON.parse(out);
551
+ expect(typeof parsed).toBe('object');
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');
561
+ } finally { rmTmpDir(tmp); }
562
+ });
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
+ });
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
+ }
@@ -0,0 +1,12 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { execFileSync } from 'node:child_process';
3
+
4
+ describe('PD CLI Smoke Test', () => {
5
+ it('should run successfully and load all dependencies including better-sqlite3 without errors', () => {
6
+ const output = execFileSync('node', ['packages/pd-cli/dist/index.js', '--help'], {
7
+ encoding: 'utf8',
8
+ cwd: process.cwd(),
9
+ });
10
+ expect(output).toContain('Usage: pd');
11
+ });
12
+ });
@@ -1,12 +0,0 @@
1
- interface IdleTriggerEvaluateOptions {
2
- workspace?: string;
3
- json?: boolean;
4
- enabled?: boolean;
5
- idleThresholdMs?: number;
6
- jitterMaxMs?: number;
7
- activityCooldownMs?: number;
8
- jitterSeed?: string;
9
- }
10
- export declare function handleRuntimeIdleTriggerEvaluate(opts: IdleTriggerEvaluateOptions): Promise<void>;
11
- export {};
12
- //# sourceMappingURL=runtime-idle-trigger.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"runtime-idle-trigger.d.ts","sourceRoot":"","sources":["../../src/commands/runtime-idle-trigger.ts"],"names":[],"mappings":"AAUA,UAAU,0BAA0B;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAmCD,wBAAsB,gCAAgC,CAAC,IAAI,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC,CAmEtG"}