peaks-cli 1.0.21 → 1.0.23
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/bin/peaks.js +0 -0
- package/dist/src/cli/commands/capability-commands.d.ts +12 -1
- package/dist/src/cli/commands/capability-commands.js +9 -11
- package/dist/src/cli/commands/config-commands.js +2 -85
- package/dist/src/cli/commands/core-artifact-commands.js +6 -1
- package/dist/src/cli/commands/request-commands.js +82 -2
- package/dist/src/cli/commands/scan-commands.js +30 -0
- package/dist/src/cli/commands/workflow-commands.js +35 -5
- package/dist/src/services/artifacts/artifact-prerequisites.js +53 -13
- package/dist/src/services/artifacts/artifact-service.js +2 -2
- package/dist/src/services/artifacts/request-artifact-service.d.ts +32 -0
- package/dist/src/services/artifacts/request-artifact-service.js +148 -16
- package/dist/src/services/artifacts/workspace-service.d.ts +1 -1
- package/dist/src/services/artifacts/workspace-service.js +10 -20
- package/dist/src/services/config/config-safety.d.ts +1 -1
- package/dist/src/services/config/config-safety.js +4 -6
- package/dist/src/services/config/config-service.d.ts +1 -1
- package/dist/src/services/config/config-service.js +67 -69
- package/dist/src/services/config/config-types.d.ts +0 -2
- package/dist/src/services/config/config-types.js +0 -2
- package/dist/src/services/mode/bypass-tracker.d.ts +4 -0
- package/dist/src/services/mode/bypass-tracker.js +31 -0
- package/dist/src/services/mode/mode-enforcement.d.ts +14 -0
- package/dist/src/services/mode/mode-enforcement.js +81 -0
- package/dist/src/services/sc/sc-service.js +5 -5
- package/dist/src/services/scan/acceptance-coverage-service.js +1 -4
- package/dist/src/services/scan/archetype-service.js +4 -15
- package/dist/src/services/scan/diff-scope-service.js +3 -3
- package/dist/src/services/scan/file-size-scan.d.ts +19 -0
- package/dist/src/services/scan/file-size-scan.js +39 -0
- package/dist/src/services/scan/type-sanity-service.js +1 -3
- package/dist/src/services/session/index.d.ts +1 -0
- package/dist/src/services/session/index.js +1 -0
- package/dist/src/services/session/session-manager.d.ts +60 -0
- package/dist/src/services/session/session-manager.js +150 -0
- package/dist/src/services/skills/skill-presence-service.d.ts +4 -1
- package/dist/src/services/skills/skill-presence-service.js +11 -1
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +31 -0
- package/dist/src/services/workflow/pipeline-verify-service.js +180 -0
- package/dist/src/services/workspace/workspace-service.js +6 -0
- package/dist/src/shared/change-id.d.ts +13 -0
- package/dist/src/shared/change-id.js +29 -1
- package/dist/src/shared/incrementing-number.d.ts +31 -0
- package/dist/src/shared/incrementing-number.js +58 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +2 -1
- package/skills/peaks-rd/SKILL.md +3 -0
- package/skills/peaks-solo/SKILL.md +42 -11
- package/skills/peaks-ui/SKILL.md +3 -0
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import { isDirectory, listDirectories
|
|
4
|
+
import { isDirectory, listDirectories } from '../../shared/fs.js';
|
|
4
5
|
import { checkPrerequisites, DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES } from './artifact-prerequisites.js';
|
|
6
|
+
import { ensureSession } from '../session/session-manager.js';
|
|
7
|
+
import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
|
|
8
|
+
import { lintRequestArtifact } from './artifact-lint-service.js';
|
|
9
|
+
import { checkTypeSanity } from '../scan/type-sanity-service.js';
|
|
10
|
+
import { requireUserConfirmation } from '../mode/mode-enforcement.js';
|
|
11
|
+
import { scanFileSize } from '../scan/file-size-scan.js';
|
|
5
12
|
export { VALID_REQUEST_TYPES, DEFAULT_REQUEST_TYPE, isRequestType };
|
|
6
13
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
7
14
|
const VALID_ROLES = new Set(['prd', 'ui', 'rd', 'qa', 'sc']);
|
|
@@ -299,17 +306,42 @@ export async function createRequestArtifact(options) {
|
|
|
299
306
|
const requestType = options.requestType ?? DEFAULT_REQUEST_TYPE;
|
|
300
307
|
const clock = options.clock ?? defaultClock;
|
|
301
308
|
const timestamp = clock();
|
|
302
|
-
|
|
303
|
-
const
|
|
309
|
+
// Use provided session ID or get/create current session
|
|
310
|
+
const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
|
|
311
|
+
// Build numbered path in session directory
|
|
312
|
+
const requestsDir = join(options.projectRoot, '.peaks', sessionId, options.role, 'requests');
|
|
313
|
+
// Check if a file with this requestId already exists (regardless of number prefix)
|
|
314
|
+
if (await isDirectory(requestsDir)) {
|
|
315
|
+
const existingFiles = await listMarkdownFiles(requestsDir);
|
|
316
|
+
const alreadyExists = existingFiles.some((file) => {
|
|
317
|
+
if (file === `${options.requestId}.md`)
|
|
318
|
+
return true;
|
|
319
|
+
if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`))
|
|
320
|
+
return true;
|
|
321
|
+
return false;
|
|
322
|
+
});
|
|
323
|
+
if (alreadyExists) {
|
|
324
|
+
throw new Error(`A request artifact with id "${options.requestId}" already exists in ${requestsDir}. Remove it before re-running peaks request init.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const number = getNextNumber(requestsDir);
|
|
328
|
+
const filename = buildNumberedFilename(number, options.requestId);
|
|
329
|
+
const path = join(requestsDir, filename);
|
|
304
330
|
const content = renderTemplate(options.role, options.requestId, sessionId, timestamp, requestType);
|
|
305
331
|
if (options.apply !== true) {
|
|
306
332
|
return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: false };
|
|
307
333
|
}
|
|
308
|
-
if (await pathExists(path)) {
|
|
309
|
-
throw new Error(`Refusing to write: ${path} already exists. Update it in place or remove it before re-running peaks request init.`);
|
|
310
|
-
}
|
|
311
334
|
await mkdir(dirname(path), { recursive: true });
|
|
312
335
|
await writeFile(path, content, 'utf8');
|
|
336
|
+
// Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked
|
|
337
|
+
if (options.role === 'qa') {
|
|
338
|
+
const qaDir = join(options.projectRoot, '.peaks', sessionId, 'qa');
|
|
339
|
+
const initiatedPath = join(qaDir, '.initiated');
|
|
340
|
+
if (!existsSync(initiatedPath)) {
|
|
341
|
+
await mkdir(qaDir, { recursive: true });
|
|
342
|
+
await writeFile(initiatedPath, '', 'utf8');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
313
345
|
return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: true };
|
|
314
346
|
}
|
|
315
347
|
function extractMetadata(markdown) {
|
|
@@ -346,7 +378,9 @@ async function readSummary(projectRoot, sessionId, role, fileName) {
|
|
|
346
378
|
const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
|
|
347
379
|
const body = await readFile(path, 'utf8');
|
|
348
380
|
const { state, createdAt, requestType } = extractMetadata(body);
|
|
349
|
-
|
|
381
|
+
// Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
|
|
382
|
+
// Only strip 3-digit zero-padded prefixes (our incrementing number format)
|
|
383
|
+
const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
|
|
350
384
|
const summary = { role, sessionId, requestId, path, state, requestType };
|
|
351
385
|
if (createdAt !== undefined) {
|
|
352
386
|
summary.createdAt = createdAt;
|
|
@@ -389,14 +423,29 @@ export async function showRequestArtifact(options) {
|
|
|
389
423
|
if (!REQUEST_ID_PATTERN.test(options.requestId)) {
|
|
390
424
|
throw new Error(`Invalid request id: ${options.requestId} (expected letters, digits, dots, underscores, or dashes)`);
|
|
391
425
|
}
|
|
392
|
-
|
|
426
|
+
// Search for files matching the requestId (supports both legacy and numbered formats)
|
|
427
|
+
const findFileInDir = async (dir) => {
|
|
428
|
+
const files = await listMarkdownFiles(dir);
|
|
429
|
+
for (const file of files) {
|
|
430
|
+
// Match legacy format: ${requestId}.md
|
|
431
|
+
if (file === `${options.requestId}.md`) {
|
|
432
|
+
return { fileName: file, path: join(dir, file) };
|
|
433
|
+
}
|
|
434
|
+
// Match numbered format: ${number}-${requestId}.md
|
|
435
|
+
if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`)) {
|
|
436
|
+
return { fileName: file, path: join(dir, file) };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
};
|
|
393
441
|
if (options.sessionId !== undefined) {
|
|
394
|
-
const
|
|
395
|
-
|
|
442
|
+
const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
|
|
443
|
+
const found = await findFileInDir(dir);
|
|
444
|
+
if (found === null) {
|
|
396
445
|
return null;
|
|
397
446
|
}
|
|
398
|
-
const summary = await readSummary(options.projectRoot, options.sessionId, options.role, fileName);
|
|
399
|
-
const content = await readFile(path, 'utf8');
|
|
447
|
+
const summary = await readSummary(options.projectRoot, options.sessionId, options.role, found.fileName);
|
|
448
|
+
const content = await readFile(found.path, 'utf8');
|
|
400
449
|
return { ...summary, content };
|
|
401
450
|
}
|
|
402
451
|
const peaksRoot = join(options.projectRoot, '.peaks');
|
|
@@ -405,10 +454,11 @@ export async function showRequestArtifact(options) {
|
|
|
405
454
|
}
|
|
406
455
|
const sessions = await listDirectories(peaksRoot);
|
|
407
456
|
for (const sessionId of sessions) {
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
457
|
+
const dir = join(peaksRoot, sessionId, options.role, 'requests');
|
|
458
|
+
const found = await findFileInDir(dir);
|
|
459
|
+
if (found !== null) {
|
|
460
|
+
const summary = await readSummary(options.projectRoot, sessionId, options.role, found.fileName);
|
|
461
|
+
const content = await readFile(found.path, 'utf8');
|
|
412
462
|
return { ...summary, content };
|
|
413
463
|
}
|
|
414
464
|
}
|
|
@@ -439,6 +489,48 @@ export class PrerequisitesNotSatisfiedError extends Error {
|
|
|
439
489
|
this.missing = missing;
|
|
440
490
|
}
|
|
441
491
|
}
|
|
492
|
+
export class LintGateError extends Error {
|
|
493
|
+
code = 'LINT_GATE_FAILED';
|
|
494
|
+
role;
|
|
495
|
+
newState;
|
|
496
|
+
errorCount;
|
|
497
|
+
constructor(role, newState, errorCount) {
|
|
498
|
+
super(`Cannot transition ${role} to ${newState}: ${errorCount} lint error(s) found in artifact. ` +
|
|
499
|
+
'Fix lint errors or use --allow-incomplete to bypass.');
|
|
500
|
+
this.name = 'LintGateError';
|
|
501
|
+
this.role = role;
|
|
502
|
+
this.newState = newState;
|
|
503
|
+
this.errorCount = errorCount;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
export class TypeSanityViolationError extends Error {
|
|
507
|
+
code = 'TYPE_SANITY_VIOLATION';
|
|
508
|
+
declaredType;
|
|
509
|
+
suggestedTypes;
|
|
510
|
+
rationale;
|
|
511
|
+
constructor(declaredType, suggestedTypes, rationale) {
|
|
512
|
+
super(`Type sanity violation: declared --type=${declaredType} disagrees with changed files. ` +
|
|
513
|
+
`Suggested types: ${suggestedTypes.join(' | ')}. ` +
|
|
514
|
+
`Rationale: ${rationale}`);
|
|
515
|
+
this.name = 'TypeSanityViolationError';
|
|
516
|
+
this.declaredType = declaredType;
|
|
517
|
+
this.suggestedTypes = suggestedTypes;
|
|
518
|
+
this.rationale = rationale;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
export class FileSizeViolationError extends Error {
|
|
522
|
+
code = 'FILE_SIZE_VIOLATION';
|
|
523
|
+
violations;
|
|
524
|
+
threshold;
|
|
525
|
+
constructor(violations, threshold) {
|
|
526
|
+
const summary = violations.map((v) => `${v.file} (${v.lines} lines)`).join(', ');
|
|
527
|
+
super(`File size violation: ${violations.length} file(s) exceed ${threshold} lines: ${summary}. ` +
|
|
528
|
+
'Split into smaller modules or use --allow-incomplete to bypass.');
|
|
529
|
+
this.name = 'FileSizeViolationError';
|
|
530
|
+
this.violations = violations;
|
|
531
|
+
this.threshold = threshold;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
442
534
|
function updateStatusBlock(markdown, newState, timestamp, reason) {
|
|
443
535
|
const lines = markdown.split(/\r?\n/);
|
|
444
536
|
let previousState = 'unknown';
|
|
@@ -499,6 +591,14 @@ export async function transitionRequestArtifact(options) {
|
|
|
499
591
|
if (existing === null) {
|
|
500
592
|
return null;
|
|
501
593
|
}
|
|
594
|
+
// Mode enforcement: require user confirmation in assisted/strict modes
|
|
595
|
+
const transitionKey = `${options.role}:${options.newState}`;
|
|
596
|
+
await requireUserConfirmation({
|
|
597
|
+
projectRoot: options.projectRoot,
|
|
598
|
+
transitionKey,
|
|
599
|
+
confirmed: options.confirmed,
|
|
600
|
+
forceConfirm: options.forceConfirm
|
|
601
|
+
});
|
|
502
602
|
const prerequisiteResult = await checkPrerequisites({
|
|
503
603
|
projectRoot: options.projectRoot,
|
|
504
604
|
sessionId: existing.sessionId,
|
|
@@ -510,6 +610,38 @@ export async function transitionRequestArtifact(options) {
|
|
|
510
610
|
if (!prerequisiteResult.ok && options.allowIncomplete !== true) {
|
|
511
611
|
throw new PrerequisitesNotSatisfiedError(options.role, options.newState, existing.sessionId, prerequisiteResult.missing);
|
|
512
612
|
}
|
|
613
|
+
// Type sanity check for PRD handoff
|
|
614
|
+
if (options.typeSanityCheck !== undefined && options.role === 'prd' && options.newState === 'handed-off') {
|
|
615
|
+
const sanityReport = checkTypeSanity({
|
|
616
|
+
projectRoot: options.typeSanityCheck.projectRoot,
|
|
617
|
+
declaredType: options.typeSanityCheck.declaredType
|
|
618
|
+
});
|
|
619
|
+
if (!sanityReport.consistent) {
|
|
620
|
+
throw new TypeSanityViolationError(options.typeSanityCheck.declaredType, sanityReport.suggestedTypes, sanityReport.rationale);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Lint gate: when transitioning OUT of draft, lint must pass (unless --allow-incomplete)
|
|
624
|
+
if (existing.state === 'draft' && options.allowIncomplete !== true) {
|
|
625
|
+
const lintReport = await lintRequestArtifact({
|
|
626
|
+
projectRoot: options.projectRoot,
|
|
627
|
+
role: options.role,
|
|
628
|
+
requestId: options.requestId,
|
|
629
|
+
sessionId: existing.sessionId
|
|
630
|
+
});
|
|
631
|
+
if (lintReport !== null && !lintReport.ok) {
|
|
632
|
+
const errorCount = lintReport.findings.filter((f) => f.severity === 'error').length;
|
|
633
|
+
if (errorCount > 0) {
|
|
634
|
+
throw new LintGateError(options.role, options.newState, errorCount);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// File size gate: when RD declares implemented, scan for oversized files (karpathy-skills "Simplicity First")
|
|
639
|
+
if (options.role === 'rd' && options.newState === 'implemented' && options.allowIncomplete !== true) {
|
|
640
|
+
const sizeResult = scanFileSize({ projectRoot: options.projectRoot });
|
|
641
|
+
if (!sizeResult.ok) {
|
|
642
|
+
throw new FileSizeViolationError(sizeResult.violations, sizeResult.threshold);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
513
645
|
const clock = options.clock ?? defaultClock;
|
|
514
646
|
const timestamp = clock();
|
|
515
647
|
const bypassNote = !prerequisiteResult.ok && options.allowIncomplete === true
|
|
@@ -20,7 +20,7 @@ export type SyncResult = {
|
|
|
20
20
|
error?: string;
|
|
21
21
|
};
|
|
22
22
|
export declare function getLocalArtifactPath(workspace: WorkspaceConfig): string;
|
|
23
|
-
export declare function isArtifactWorkspaceOutsideTarget(
|
|
23
|
+
export declare function isArtifactWorkspaceOutsideTarget(_workspace: WorkspaceConfig, _artifactWorkspacePath?: string): boolean;
|
|
24
24
|
export declare function hasValidArtifactWorkspace(workspace: WorkspaceConfig, artifactWorkspacePath?: string): boolean;
|
|
25
25
|
export declare function getArtifactRemoteRepo(workspace: WorkspaceConfig): WorkspaceConfig['artifactRepo'] | null;
|
|
26
26
|
export declare function executeArtifactSync(workspaceId?: string): Promise<SyncResult>;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { resolve } from 'node:path';
|
|
5
4
|
import { isInsidePath, stablePath } from '../../shared/path-utils.js';
|
|
6
|
-
import {
|
|
5
|
+
import { getWorkspaceConfig, getWorkspaceConfigForCurrentPath } from '../config/config-service.js';
|
|
7
6
|
import { pathExists } from '../../shared/fs.js';
|
|
8
7
|
import { execCommand } from '../../shared/process.js';
|
|
9
8
|
function canonicalPath(path) {
|
|
@@ -16,12 +15,10 @@ export function getLocalArtifactPath(workspace) {
|
|
|
16
15
|
if (workspace.artifactStorage?.localPath) {
|
|
17
16
|
return resolve(workspace.artifactStorage.localPath);
|
|
18
17
|
}
|
|
19
|
-
return resolve(
|
|
18
|
+
return resolve(workspace.rootPath, '.peaks', 'artifacts');
|
|
20
19
|
}
|
|
21
|
-
export function isArtifactWorkspaceOutsideTarget(
|
|
22
|
-
|
|
23
|
-
const artifactRoot = canonicalPath(artifactWorkspacePath);
|
|
24
|
-
return !isInsidePath(artifactRoot, targetRoot);
|
|
20
|
+
export function isArtifactWorkspaceOutsideTarget(_workspace, _artifactWorkspacePath) {
|
|
21
|
+
return true;
|
|
25
22
|
}
|
|
26
23
|
export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = getLocalArtifactPath(workspace)) {
|
|
27
24
|
if (!isArtifactWorkspaceOutsideTarget(workspace, artifactWorkspacePath))
|
|
@@ -30,7 +27,6 @@ export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = get
|
|
|
30
27
|
const peaksRoot = canonicalChildPath(artifactWorkspacePath, '.peaks');
|
|
31
28
|
const changesRoot = canonicalChildPath(artifactWorkspacePath, '.peaks', 'changes');
|
|
32
29
|
const configPath = canonicalChildPath(artifactWorkspacePath, '.peaks', 'config.json');
|
|
33
|
-
const targetRoot = canonicalPath(workspace.rootPath);
|
|
34
30
|
if (!existsSync(resolve(artifactWorkspacePath, '.peaks', 'config.json')))
|
|
35
31
|
return false;
|
|
36
32
|
if (!isInsidePath(peaksRoot, artifactRoot))
|
|
@@ -39,12 +35,6 @@ export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = get
|
|
|
39
35
|
return false;
|
|
40
36
|
if (!isInsidePath(configPath, artifactRoot))
|
|
41
37
|
return false;
|
|
42
|
-
if (isInsidePath(peaksRoot, targetRoot))
|
|
43
|
-
return false;
|
|
44
|
-
if (isInsidePath(changesRoot, targetRoot))
|
|
45
|
-
return false;
|
|
46
|
-
if (isInsidePath(configPath, targetRoot))
|
|
47
|
-
return false;
|
|
48
38
|
return true;
|
|
49
39
|
}
|
|
50
40
|
export function getArtifactRemoteRepo(workspace) {
|
|
@@ -88,8 +78,8 @@ function redactSecrets(message) {
|
|
|
88
78
|
}
|
|
89
79
|
export async function executeArtifactSync(workspaceId) {
|
|
90
80
|
const workspace = workspaceId
|
|
91
|
-
?
|
|
92
|
-
:
|
|
81
|
+
? getWorkspaceConfig(workspaceId)
|
|
82
|
+
: getWorkspaceConfigForCurrentPath();
|
|
93
83
|
if (!workspace) {
|
|
94
84
|
return {
|
|
95
85
|
workspaceId: workspaceId ?? 'unknown',
|
|
@@ -190,8 +180,8 @@ export async function executeArtifactSync(workspaceId) {
|
|
|
190
180
|
}
|
|
191
181
|
export function getArtifactWorkspaceStatus(workspaceId) {
|
|
192
182
|
const workspace = workspaceId
|
|
193
|
-
?
|
|
194
|
-
:
|
|
183
|
+
? getWorkspaceConfig(workspaceId)
|
|
184
|
+
: getWorkspaceConfigForCurrentPath();
|
|
195
185
|
if (!workspace) {
|
|
196
186
|
return {
|
|
197
187
|
workspaceId: workspaceId ?? 'unknown',
|
|
@@ -230,8 +220,8 @@ export function getArtifactWorkspaceStatus(workspaceId) {
|
|
|
230
220
|
}
|
|
231
221
|
export function planArtifactSync(workspaceId, dryRun = true) {
|
|
232
222
|
const workspace = workspaceId
|
|
233
|
-
?
|
|
234
|
-
:
|
|
223
|
+
? getWorkspaceConfig(workspaceId)
|
|
224
|
+
: getWorkspaceConfigForCurrentPath();
|
|
235
225
|
if (!workspace) {
|
|
236
226
|
return {
|
|
237
227
|
workspaceId: workspaceId ?? 'unknown',
|
|
@@ -6,7 +6,7 @@ export declare function getProjectConfigPath(projectRoot: string | null): string
|
|
|
6
6
|
export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
|
|
7
7
|
export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
|
|
8
8
|
export declare function validateUserConfigPathForWrite(configPath: string): void;
|
|
9
|
-
export declare function validateArtifactWorkspaceRoot(artifactRoot: string,
|
|
9
|
+
export declare function validateArtifactWorkspaceRoot(artifactRoot: string, _workspaceRoot: string): void;
|
|
10
10
|
export declare function validateArtifactWorkspaceMarkerPath(artifactRoot: string, peaksPath: string, markerPath: string): void;
|
|
11
11
|
export declare function readConfigFileSafely(configPath: string, errorMessage: string): string;
|
|
12
12
|
export declare function writeConfigFileSafely(configPath: string, content: string, validateBeforeWrite: () => void, errorMessage: string): void;
|
|
@@ -54,6 +54,9 @@ export function findProjectRoot(startPath) {
|
|
|
54
54
|
if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
|
|
55
55
|
return current;
|
|
56
56
|
}
|
|
57
|
+
if (existsSync(resolve(current, 'package.json')) || existsSync(resolve(current, '.git'))) {
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
57
60
|
parent = current;
|
|
58
61
|
current = dirname(parent);
|
|
59
62
|
}
|
|
@@ -154,16 +157,11 @@ export function validateUserConfigPathForWrite(configPath) {
|
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
|
-
export function validateArtifactWorkspaceRoot(artifactRoot,
|
|
160
|
+
export function validateArtifactWorkspaceRoot(artifactRoot, _workspaceRoot) {
|
|
158
161
|
const artifactStats = lstatSync(artifactRoot);
|
|
159
162
|
if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
|
|
160
163
|
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
161
164
|
}
|
|
162
|
-
const artifactRootReal = realpathSync(artifactRoot);
|
|
163
|
-
const workspaceRootReal = realpathSync(workspaceRoot);
|
|
164
|
-
if (isInsidePath(artifactRootReal, workspaceRootReal)) {
|
|
165
|
-
throw new Error('Artifact workspace must stay outside the project root');
|
|
166
|
-
}
|
|
167
165
|
}
|
|
168
166
|
export function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
|
|
169
167
|
const artifactStats = lstatSync(artifactRoot);
|
|
@@ -23,7 +23,7 @@ export declare function readConfig(projectRoot?: string | null): PeaksConfig;
|
|
|
23
23
|
export declare function writeConfig(partial: Partial<PeaksConfig>, layer?: ConfigLayer): void;
|
|
24
24
|
export declare function getConfig(options?: ConfigGetOptions): unknown;
|
|
25
25
|
export declare function setConfig(options: ConfigSetOptions): void;
|
|
26
|
-
export declare function getWorkspaceConfig(workspaceId: string,
|
|
26
|
+
export declare function getWorkspaceConfig(workspaceId: string, _projectRoot?: string | null): WorkspaceConfig | null;
|
|
27
27
|
export declare function addWorkspace(workspace: WorkspaceConfig, layer?: ConfigLayer): void;
|
|
28
28
|
export declare function removeWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
|
|
29
29
|
export declare function setCurrentWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
4
3
|
import { DEFAULT_CONFIG } from './config-types.js';
|
|
5
4
|
import { stablePath } from '../../shared/path-utils.js';
|
|
6
5
|
import { findProjectRoot, getProjectBootstrapConfigPath, getProjectConfigPath, getUserConfigPath, isInsidePath, readConfigFileSafely, resolveProjectRootForConfig, validateArtifactWorkspaceMarkerPath, validateArtifactWorkspaceRoot, validateProjectBootstrapConfigPathForWrite, validateUserConfigPathForWrite, writeConfigFileSafely, writeProjectConfigFile, writeUserConfigFile } from './config-safety.js';
|
|
@@ -199,10 +198,6 @@ function isRecord(value) {
|
|
|
199
198
|
function isSafeConfigSegment(value) {
|
|
200
199
|
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !value.includes('..') && !value.endsWith('.');
|
|
201
200
|
}
|
|
202
|
-
function toSafeConfigSegment(value) {
|
|
203
|
-
const normalized = value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').replace(/\.+$/g, '');
|
|
204
|
-
return isSafeConfigSegment(normalized) ? normalized : 'workspace';
|
|
205
|
-
}
|
|
206
201
|
function toArtifactRemoteRepoConfig(value) {
|
|
207
202
|
if (!isRecord(value) || (value.provider !== 'github' && value.provider !== 'gitlab') || typeof value.owner !== 'string' || typeof value.name !== 'string') {
|
|
208
203
|
return null;
|
|
@@ -246,15 +241,6 @@ function toWorkspaceConfig(value) {
|
|
|
246
241
|
function toWorkspaceConfigs(value) {
|
|
247
242
|
return Array.isArray(value) ? value.map(toWorkspaceConfig).filter((workspace) => workspace !== null) : [];
|
|
248
243
|
}
|
|
249
|
-
function mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces) {
|
|
250
|
-
const merged = new Map(userWorkspaces.map((workspace) => [workspace.workspaceId, workspace]));
|
|
251
|
-
for (const workspace of projectWorkspaces) {
|
|
252
|
-
if (!merged.has(workspace.workspaceId)) {
|
|
253
|
-
merged.set(workspace.workspaceId, workspace);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return [...merged.values()];
|
|
257
|
-
}
|
|
258
244
|
function toProviderModelConfig(value) {
|
|
259
245
|
if (!isRecord(value))
|
|
260
246
|
return {};
|
|
@@ -400,8 +386,6 @@ function toPeaksConfig(value) {
|
|
|
400
386
|
const proxy = toProxyConfig(value.proxy);
|
|
401
387
|
return {
|
|
402
388
|
...(typeof value.version === 'string' ? { version: value.version } : {}),
|
|
403
|
-
...(typeof value.currentWorkspace === 'string' ? { currentWorkspace: value.currentWorkspace } : {}),
|
|
404
|
-
...(Array.isArray(value.workspaces) ? { workspaces: toWorkspaceConfigs(value.workspaces) } : {}),
|
|
405
389
|
...(typeof value.language === 'string' ? { language: value.language } : {}),
|
|
406
390
|
...(typeof value.model === 'string' && ['haiku', 'sonnet', 'opus', 'minimax'].includes(value.model) ? { model: value.model } : {}),
|
|
407
391
|
...(typeof value.economyMode === 'boolean' ? { economyMode: value.economyMode } : {}),
|
|
@@ -424,15 +408,23 @@ export function readConfig(projectRoot) {
|
|
|
424
408
|
const detectedRoot = projectRoot ?? findProjectRoot(process.cwd());
|
|
425
409
|
const userConfig = toPeaksConfig(readUserJsonFile());
|
|
426
410
|
const projectConfig = removeProjectSensitiveConfig(toPeaksConfig(readProjectJsonFile(detectedRoot)));
|
|
427
|
-
const { proxy: projectProxy,
|
|
428
|
-
const userWorkspaces = userConfig.workspaces ?? [];
|
|
411
|
+
const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
|
|
429
412
|
return {
|
|
430
413
|
...DEFAULT_CONFIG,
|
|
431
414
|
...userConfig,
|
|
432
|
-
...projectConfigWithoutProxy
|
|
433
|
-
workspaces: mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces ?? [])
|
|
415
|
+
...projectConfigWithoutProxy
|
|
434
416
|
};
|
|
435
417
|
}
|
|
418
|
+
function sanitizeWorkspacePartial(partial) {
|
|
419
|
+
const result = { ...partial };
|
|
420
|
+
if (Array.isArray(result.workspaces)) {
|
|
421
|
+
result.workspaces = toWorkspaceConfigs(result.workspaces);
|
|
422
|
+
}
|
|
423
|
+
if (typeof result.currentWorkspace !== 'string' && result.currentWorkspace !== null && result.currentWorkspace !== undefined) {
|
|
424
|
+
delete result.currentWorkspace;
|
|
425
|
+
}
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
436
428
|
export function writeConfig(partial, layer = 'user') {
|
|
437
429
|
if (!isConfigLayer(layer)) {
|
|
438
430
|
throw new Error('Invalid config layer');
|
|
@@ -446,29 +438,28 @@ export function writeConfig(partial, layer = 'user') {
|
|
|
446
438
|
const { projectRoot, configPath } = getProjectWriteTarget();
|
|
447
439
|
ensureDir(dirname(configPath));
|
|
448
440
|
const existing = readJsonFile(configPath, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath)) ?? {};
|
|
449
|
-
const merged = { ...existing, ...partial };
|
|
441
|
+
const merged = sanitizeWorkspacePartial({ ...existing, ...partial });
|
|
450
442
|
writeProjectConfigFile(projectRoot, configPath, JSON.stringify(merged, null, 2));
|
|
451
443
|
return;
|
|
452
444
|
}
|
|
453
445
|
const userPath = getUserConfigPath();
|
|
454
446
|
ensureDir(dirname(userPath));
|
|
455
447
|
const existing = readJsonFile(userPath, () => validateUserConfigPathForWrite(userPath)) ?? {};
|
|
456
|
-
const merged = { ...existing, ...partial };
|
|
448
|
+
const merged = sanitizeWorkspacePartial({ ...existing, ...partial });
|
|
457
449
|
writeUserConfigFile(userPath, JSON.stringify(merged, null, 2));
|
|
458
450
|
}
|
|
459
451
|
export function getConfig(options = {}) {
|
|
460
452
|
const projectRoot = findProjectRoot(process.cwd());
|
|
461
453
|
const userConfig = readUserJsonFile() ?? {};
|
|
462
454
|
const projectConfig = removeProjectSensitiveConfig(readProjectJsonFile(projectRoot) ?? {});
|
|
463
|
-
const { proxy: projectProxy,
|
|
455
|
+
const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
|
|
464
456
|
const source = options.layer === 'user'
|
|
465
457
|
? userConfig
|
|
466
458
|
: options.layer === 'project'
|
|
467
459
|
? projectConfig
|
|
468
460
|
: {
|
|
469
461
|
...userConfig,
|
|
470
|
-
...projectConfigWithoutProxy
|
|
471
|
-
workspaces: mergeWorkspaceConfigs(toWorkspaceConfigs(userConfig.workspaces), toWorkspaceConfigs(projectWorkspaces))
|
|
462
|
+
...projectConfigWithoutProxy
|
|
472
463
|
};
|
|
473
464
|
const config = isRecord(source) ? { ...source, ...(source.tokens !== undefined ? { tokens: toTokenConfig(source.tokens) } : {}) } : source;
|
|
474
465
|
if (options.key !== undefined) {
|
|
@@ -514,11 +505,7 @@ export function setConfig(options) {
|
|
|
514
505
|
writeUserConfigFile(targetPath, content);
|
|
515
506
|
}
|
|
516
507
|
}
|
|
517
|
-
|
|
518
|
-
const config = readConfig(projectRoot ?? findProjectRoot(process.cwd()));
|
|
519
|
-
return config.workspaces.find((w) => w.workspaceId === workspaceId) ?? null;
|
|
520
|
-
}
|
|
521
|
-
function readLayerConfig(layer) {
|
|
508
|
+
function readRawWorkspaceData(layer) {
|
|
522
509
|
const config = getConfig({ layer });
|
|
523
510
|
return isRecord(config)
|
|
524
511
|
? {
|
|
@@ -527,61 +514,87 @@ function readLayerConfig(layer) {
|
|
|
527
514
|
}
|
|
528
515
|
: { currentWorkspace: null, workspaces: [] };
|
|
529
516
|
}
|
|
517
|
+
function writeRawWorkspaceData(data, layer) {
|
|
518
|
+
const projectTarget = layer === 'project' ? getProjectWriteTarget() : null;
|
|
519
|
+
const targetPath = projectTarget?.configPath ?? getUserConfigPath();
|
|
520
|
+
ensureDir(dirname(targetPath));
|
|
521
|
+
const existing = projectTarget
|
|
522
|
+
? readJsonFile(targetPath, () => validateProjectBootstrapConfigPathForWrite(projectTarget.projectRoot, targetPath)) ?? {}
|
|
523
|
+
: readJsonFile(targetPath, () => validateUserConfigPathForWrite(targetPath)) ?? {};
|
|
524
|
+
const merged = { ...existing, ...data };
|
|
525
|
+
const content = JSON.stringify(merged, null, 2);
|
|
526
|
+
if (projectTarget) {
|
|
527
|
+
writeProjectConfigFile(projectTarget.projectRoot, targetPath, content);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
writeUserConfigFile(targetPath, content);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function readAllWorkspaces() {
|
|
534
|
+
const userData = readRawWorkspaceData('user');
|
|
535
|
+
const projectData = readRawWorkspaceData('project');
|
|
536
|
+
const mergedWorkspaces = new Map();
|
|
537
|
+
for (const w of userData.workspaces)
|
|
538
|
+
mergedWorkspaces.set(w.workspaceId, w);
|
|
539
|
+
for (const w of projectData.workspaces)
|
|
540
|
+
mergedWorkspaces.set(w.workspaceId, w);
|
|
541
|
+
return {
|
|
542
|
+
currentWorkspace: projectData.currentWorkspace ?? userData.currentWorkspace,
|
|
543
|
+
workspaces: [...mergedWorkspaces.values()]
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
export function getWorkspaceConfig(workspaceId, _projectRoot) {
|
|
547
|
+
const { workspaces } = readAllWorkspaces();
|
|
548
|
+
return workspaces.find((w) => w.workspaceId === workspaceId) ?? null;
|
|
549
|
+
}
|
|
550
|
+
function readLayerConfig(layer) {
|
|
551
|
+
return readRawWorkspaceData(layer);
|
|
552
|
+
}
|
|
530
553
|
export function addWorkspace(workspace, layer = 'user') {
|
|
531
554
|
if (!isSafeConfigSegment(workspace.workspaceId)) {
|
|
532
555
|
throw new Error('Workspace id must only contain letters, numbers, dots, underscores, or hyphens and must not contain path traversal');
|
|
533
556
|
}
|
|
534
|
-
const config =
|
|
557
|
+
const config = readRawWorkspaceData(layer);
|
|
535
558
|
const workspaces = config.workspaces;
|
|
536
559
|
const existing = workspaces.findIndex((w) => w.workspaceId === workspace.workspaceId);
|
|
537
560
|
const updatedWorkspaces = existing >= 0
|
|
538
561
|
? workspaces.map((existingWorkspace) => existingWorkspace.workspaceId === workspace.workspaceId ? workspace : existingWorkspace)
|
|
539
562
|
: [...workspaces, workspace];
|
|
540
|
-
|
|
563
|
+
writeRawWorkspaceData({ workspaces: updatedWorkspaces }, layer);
|
|
541
564
|
}
|
|
542
565
|
export function removeWorkspace(workspaceId, layer = 'user') {
|
|
543
566
|
if (!isSafeConfigSegment(workspaceId))
|
|
544
567
|
return false;
|
|
545
|
-
const config =
|
|
568
|
+
const config = readRawWorkspaceData(layer);
|
|
546
569
|
const workspaces = config.workspaces;
|
|
547
570
|
const idx = workspaces.findIndex((w) => w.workspaceId === workspaceId);
|
|
548
571
|
if (idx < 0)
|
|
549
572
|
return false;
|
|
550
573
|
const updatedWorkspaces = workspaces.filter((w) => w.workspaceId !== workspaceId);
|
|
551
574
|
const currentWorkspace = config.currentWorkspace === workspaceId ? updatedWorkspaces[0]?.workspaceId ?? null : config.currentWorkspace ?? null;
|
|
552
|
-
|
|
575
|
+
writeRawWorkspaceData({ workspaces: updatedWorkspaces, currentWorkspace }, layer);
|
|
553
576
|
return true;
|
|
554
577
|
}
|
|
555
578
|
export function setCurrentWorkspace(workspaceId, layer = 'user') {
|
|
556
579
|
if (!isSafeConfigSegment(workspaceId))
|
|
557
580
|
return false;
|
|
558
|
-
const config =
|
|
581
|
+
const config = readRawWorkspaceData(layer);
|
|
559
582
|
const workspaces = config.workspaces;
|
|
560
583
|
const exists = workspaces.some((w) => w.workspaceId === workspaceId);
|
|
561
584
|
if (!exists)
|
|
562
585
|
return false;
|
|
563
|
-
|
|
586
|
+
writeRawWorkspaceData({ currentWorkspace: workspaceId }, layer);
|
|
564
587
|
return true;
|
|
565
588
|
}
|
|
566
589
|
export function getCurrentWorkspaceConfig() {
|
|
567
|
-
const
|
|
568
|
-
if (!
|
|
590
|
+
const { currentWorkspace, workspaces } = readAllWorkspaces();
|
|
591
|
+
if (!currentWorkspace)
|
|
569
592
|
return null;
|
|
570
|
-
return
|
|
593
|
+
return workspaces.find((w) => w.workspaceId === currentWorkspace) ?? null;
|
|
571
594
|
}
|
|
572
595
|
export function getWorkspaceConfigForPath(path = process.cwd()) {
|
|
573
|
-
const
|
|
574
|
-
return findWorkspaceForPath(
|
|
575
|
-
}
|
|
576
|
-
function createWorkspaceId(projectRoot, existingIds) {
|
|
577
|
-
const base = toSafeConfigSegment(basename(projectRoot));
|
|
578
|
-
if (!existingIds.has(base))
|
|
579
|
-
return base;
|
|
580
|
-
let suffix = 2;
|
|
581
|
-
while (existingIds.has(`${base}-${suffix}`)) {
|
|
582
|
-
suffix += 1;
|
|
583
|
-
}
|
|
584
|
-
return `${base}-${suffix}`;
|
|
596
|
+
const { workspaces } = readAllWorkspaces();
|
|
597
|
+
return findWorkspaceForPath(workspaces, path);
|
|
585
598
|
}
|
|
586
599
|
function findWorkspaceForPath(workspaces, path) {
|
|
587
600
|
const targetPath = stablePath(path);
|
|
@@ -596,7 +609,7 @@ function findWorkspaceForPath(workspaces, path) {
|
|
|
596
609
|
return matches.reduce((best, match) => match.rootPath.length > best.rootPath.length ? match : best).workspace;
|
|
597
610
|
}
|
|
598
611
|
function getWorkspaceArtifactRoot(workspace) {
|
|
599
|
-
return workspace.artifactStorage?.localPath ? resolve(workspace.artifactStorage.localPath) : resolve(
|
|
612
|
+
return workspace.artifactStorage?.localPath ? resolve(workspace.artifactStorage.localPath) : resolve(workspace.rootPath, '.peaks', 'artifacts');
|
|
600
613
|
}
|
|
601
614
|
function ensureArtifactWorkspaceMarker(workspace) {
|
|
602
615
|
const artifactRoot = getWorkspaceArtifactRoot(workspace);
|
|
@@ -618,24 +631,9 @@ export function ensureWorkspaceConfigForPath(path = process.cwd()) {
|
|
|
618
631
|
const existingWorkspace = findWorkspaceForPath(config.workspaces, path);
|
|
619
632
|
if (existingWorkspace) {
|
|
620
633
|
ensureArtifactWorkspaceMarker(existingWorkspace);
|
|
621
|
-
if (!config.currentWorkspace) {
|
|
622
|
-
writeConfig({ currentWorkspace: existingWorkspace.workspaceId }, 'user');
|
|
623
|
-
}
|
|
624
634
|
return existingWorkspace;
|
|
625
635
|
}
|
|
626
|
-
|
|
627
|
-
const workspaceId = createWorkspaceId(projectRoot, existingIds);
|
|
628
|
-
const workspace = {
|
|
629
|
-
workspaceId,
|
|
630
|
-
name: basename(projectRoot) || 'Workspace',
|
|
631
|
-
rootPath: stablePath(projectRoot),
|
|
632
|
-
artifactStorage: { mode: 'local', localPath: resolve(homedir(), '.peaks', 'workspaces', workspaceId, 'artifacts') },
|
|
633
|
-
installedCapabilityIds: []
|
|
634
|
-
};
|
|
635
|
-
ensureArtifactWorkspaceMarker(workspace);
|
|
636
|
-
const updatedWorkspaces = [...config.workspaces, workspace];
|
|
637
|
-
writeConfig({ workspaces: updatedWorkspaces, ...(!config.currentWorkspace ? { currentWorkspace: workspace.workspaceId } : {}) }, 'user');
|
|
638
|
-
return workspace;
|
|
636
|
+
return null;
|
|
639
637
|
}
|
|
640
638
|
export function getWorkspaceConfigForCurrentPath() {
|
|
641
639
|
return getWorkspaceConfigForPath(process.cwd());
|