peaks-cli 1.0.14 → 1.0.16
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/project-commands.js +5 -0
- package/dist/src/cli/commands/request-commands.js +1 -1
- package/dist/src/cli/commands/workflow-commands.js +38 -0
- package/dist/src/services/artifacts/request-artifact-service.d.ts +2 -2
- package/dist/src/services/artifacts/request-artifact-service.js +60 -5
- package/dist/src/services/config/config-safety.d.ts +14 -0
- package/dist/src/services/config/config-safety.js +275 -0
- package/dist/src/services/config/config-service.d.ts +1 -1
- package/dist/src/services/config/config-service.js +5 -274
- package/dist/src/services/dashboard/project-dashboard-service.d.ts +11 -0
- package/dist/src/services/dashboard/project-dashboard-service.js +21 -2
- package/dist/src/services/doctor/doctor-service.d.ts +3 -0
- package/dist/src/services/doctor/doctor-service.js +58 -0
- package/dist/src/services/workflow/autonomous-resume-writer.d.ts +16 -0
- package/dist/src/services/workflow/autonomous-resume-writer.js +156 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-prd/SKILL.md +56 -1
- package/skills/peaks-qa/SKILL.md +175 -15
- package/skills/peaks-rd/SKILL.md +198 -56
- package/skills/peaks-sc/SKILL.md +66 -5
- package/skills/peaks-solo/SKILL.md +417 -65
- package/skills/peaks-solo/references/artifact-contracts.md +60 -2
- package/skills/peaks-solo/results.tsv +1 -0
- package/skills/peaks-txt/SKILL.md +68 -1
- package/skills/peaks-ui/SKILL.md +185 -18
package/bin/peaks.js
CHANGED
|
File without changes
|
|
@@ -22,6 +22,11 @@ export function registerProjectCommands(program, io) {
|
|
|
22
22
|
process.exitCode = 1;
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
|
+
if (dashboard.skillPresence.active && !dashboard.skillPresence.fresh) {
|
|
26
|
+
printResult(io, fail('project.dashboard', 'PROJECT_DASHBOARD_STALE_SKILL_PRESENCE', `Active Peaks skill presence ${dashboard.skillPresence.skill ?? '<unknown>'} is stale (set ${dashboard.skillPresence.setAt ?? '<unknown>'})`, dashboard, ['Run `peaks skill presence:clear` if the role has ended, or `peaks skill presence:set <skill>` to refresh it']), options.json);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
25
30
|
if (!dashboard.doctor.ok) {
|
|
26
31
|
printResult(io, fail('project.dashboard', 'PROJECT_DASHBOARD_DOCTOR_FAILED', `Doctor reports ${dashboard.doctor.failed} failed check(s) (${dashboard.doctor.passed} passed)`, dashboard, ['Run `peaks doctor --json` and resolve the failing checks before re-running the dashboard']), options.json);
|
|
27
32
|
process.exitCode = 1;
|
|
@@ -2,7 +2,7 @@ import { InvalidArgumentError } from 'commander';
|
|
|
2
2
|
import { allowedStatesForRole, createRequestArtifact, listRequestArtifacts, showRequestArtifact, transitionRequestArtifact } from '../../services/artifacts/request-artifact-service.js';
|
|
3
3
|
import { fail, ok } from '../../shared/result.js';
|
|
4
4
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
5
|
-
const VALID_ROLES = ['prd', 'ui', 'rd', 'qa'];
|
|
5
|
+
const VALID_ROLES = ['prd', 'ui', 'rd', 'qa', 'sc'];
|
|
6
6
|
function parseRole(value) {
|
|
7
7
|
if (!VALID_ROLES.includes(value)) {
|
|
8
8
|
throw new InvalidArgumentError(`must be one of ${VALID_ROLES.join(', ')}`);
|
|
@@ -2,6 +2,7 @@ import { createRdSwarmPlan } from '../../services/rd/rd-service.js';
|
|
|
2
2
|
import { createTechPlan, getTechStatus } from '../../services/tech/tech-service.js';
|
|
3
3
|
import { createWorkflowRouterPlan, isSoloMode, isWorkflowMode } from '../../services/workflow/workflow-router-service.js';
|
|
4
4
|
import { createAutonomousWorkflowPlan } from '../../services/workflow/workflow-autonomous-service.js';
|
|
5
|
+
import { writeAutonomousResumeArtifacts } from '../../services/workflow/autonomous-resume-writer.js';
|
|
5
6
|
import { createRecommendationPlan } from '../../services/recommendations/recommendation-service.js';
|
|
6
7
|
import { createRefactorDryRun } from '../../services/refactor/refactor-service.js';
|
|
7
8
|
import { ensureWorkspaceConfigForCurrentPath, getCurrentWorkspaceConfig, readConfig } from '../../services/config/config-service.js';
|
|
@@ -196,6 +197,31 @@ function runSwarmPlan(io, options) {
|
|
|
196
197
|
process.exitCode = 1;
|
|
197
198
|
}
|
|
198
199
|
}
|
|
200
|
+
async function runAutonomousResumeInit(io, options) {
|
|
201
|
+
try {
|
|
202
|
+
if (!options.project || !options.project.trim()) {
|
|
203
|
+
throw new Error('Project path must be non-empty');
|
|
204
|
+
}
|
|
205
|
+
const result = await writeAutonomousResumeArtifacts({
|
|
206
|
+
changeId: options.changeId,
|
|
207
|
+
goal: options.goal,
|
|
208
|
+
artifactWorkspacePath: options.project,
|
|
209
|
+
apply: options.apply === true
|
|
210
|
+
});
|
|
211
|
+
const data = {
|
|
212
|
+
applied: result.applied,
|
|
213
|
+
files: result.files.map((file) => file.path)
|
|
214
|
+
};
|
|
215
|
+
const nextActions = result.applied
|
|
216
|
+
? ['Run peaks workflow autonomous --change-id ' + options.changeId + ' --goal "<goal>" --json to verify resumePlan.status']
|
|
217
|
+
: ['Re-run with --apply to write the resume scaffold to disk'];
|
|
218
|
+
printResult(io, ok('autonomous-resume.init', data, [], nextActions), options.json);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
printResult(io, fail('autonomous-resume.init', 'AUTONOMOUS_RESUME_INIT_FAILED', getErrorMessage(error), {}, ['Use a safe change id, a non-empty goal, and a writable project path']), options.json);
|
|
222
|
+
process.exitCode = 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
199
225
|
function addTechPlanOptions(command) {
|
|
200
226
|
return addJsonOption(command
|
|
201
227
|
.description('Generate a technical dry-run graph')
|
|
@@ -232,6 +258,14 @@ function addSwarmPlanOptions(command, includeSkill) {
|
|
|
232
258
|
}
|
|
233
259
|
return addJsonOption(configured);
|
|
234
260
|
}
|
|
261
|
+
function addAutonomousResumeInitOptions(command) {
|
|
262
|
+
return addJsonOption(command
|
|
263
|
+
.description('Write the autonomous resume artifact scaffold for a change-id')
|
|
264
|
+
.requiredOption('--change-id <id>', 'change identifier')
|
|
265
|
+
.requiredOption('--goal <goal>', 'planning goal')
|
|
266
|
+
.requiredOption('--project <path>', 'artifact workspace path to write under')
|
|
267
|
+
.option('--apply', 'write the artifacts to disk (default is dry-run preview)'));
|
|
268
|
+
}
|
|
235
269
|
export function registerWorkflowCommands(program, io) {
|
|
236
270
|
const refactor = program.command('refactor').description('Plan a Peaks refactor run without modifying code');
|
|
237
271
|
addJsonOption(refactor
|
|
@@ -261,6 +295,10 @@ export function registerWorkflowCommands(program, io) {
|
|
|
261
295
|
addWorkflowRouteOptions(program.command('route'), 'Plan a workflow routing dry-run summary').action((options) => runWorkflowRoute(io, options));
|
|
262
296
|
addWorkflowRouteOptions(workflow.command('autonomous'), 'Plan an autonomous workflow handoff summary').action((options) => runAutonomousWorkflow(io, options));
|
|
263
297
|
addWorkflowRouteOptions(program.command('autonomous'), 'Plan an autonomous workflow handoff summary').action((options) => runAutonomousWorkflow(io, options));
|
|
298
|
+
const autonomousResume = workflow.command('autonomous-resume').description('Manage autonomous workflow resume artifacts');
|
|
299
|
+
addAutonomousResumeInitOptions(autonomousResume.command('init')).action((options) => runAutonomousResumeInit(io, options));
|
|
300
|
+
const autonomousResumeAlias = program.command('autonomous-resume').description('Manage autonomous workflow resume artifacts');
|
|
301
|
+
addAutonomousResumeInitOptions(autonomousResumeAlias.command('init')).action((options) => runAutonomousResumeInit(io, options));
|
|
264
302
|
const swarm = program.command('swarm').description('Plan RD swarm dry-run graphs');
|
|
265
303
|
addSwarmPlanOptions(swarm.command('plan'), true).action((options) => runSwarmPlan(io, options));
|
|
266
304
|
addSwarmPlanOptions(program.command('swarm-plan'), false).action((options) => runSwarmPlan(io, options));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type RequestArtifactRole = 'prd' | 'ui' | 'rd' | 'qa';
|
|
1
|
+
export type RequestArtifactRole = 'prd' | 'ui' | 'rd' | 'qa' | 'sc';
|
|
2
2
|
export type CreateRequestArtifactOptions = {
|
|
3
3
|
role: RequestArtifactRole;
|
|
4
4
|
requestId: string;
|
|
@@ -40,7 +40,7 @@ export type ShowRequestArtifactResult = RequestArtifactSummary & {
|
|
|
40
40
|
};
|
|
41
41
|
export declare function listRequestArtifacts(options: ListRequestArtifactsOptions): Promise<RequestArtifactSummary[]>;
|
|
42
42
|
export declare function showRequestArtifact(options: ShowRequestArtifactOptions): Promise<ShowRequestArtifactResult | null>;
|
|
43
|
-
export type RequestArtifactState = 'draft' | 'confirmed-by-user' | 'direction-locked' | 'spec-locked' | 'implemented' | 'qa-handoff' | 'running' | 'verdict-issued' | 'handed-off' | 'blocked';
|
|
43
|
+
export type RequestArtifactState = 'draft' | 'confirmed-by-user' | 'direction-locked' | 'spec-locked' | 'implemented' | 'qa-handoff' | 'running' | 'verdict-issued' | 'impact-recorded' | 'boundary-recorded' | 'handed-off' | 'blocked';
|
|
44
44
|
export declare function allowedStatesForRole(role: RequestArtifactRole): ReadonlyArray<RequestArtifactState>;
|
|
45
45
|
export type TransitionRequestArtifactOptions = {
|
|
46
46
|
role: RequestArtifactRole;
|
|
@@ -2,7 +2,7 @@ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { isDirectory, listDirectories, pathExists } from '../../shared/fs.js';
|
|
4
4
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
5
|
-
const VALID_ROLES = new Set(['prd', 'ui', 'rd', 'qa']);
|
|
5
|
+
const VALID_ROLES = new Set(['prd', 'ui', 'rd', 'qa', 'sc']);
|
|
6
6
|
function defaultClock() {
|
|
7
7
|
return new Date().toISOString();
|
|
8
8
|
}
|
|
@@ -215,6 +215,58 @@ function renderQaTemplate(requestId, sessionId, timestamp) {
|
|
|
215
215
|
|
|
216
216
|
## Status
|
|
217
217
|
|
|
218
|
+
- created: ${timestamp}
|
|
219
|
+
- last update: ${timestamp}
|
|
220
|
+
- state: draft
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
function renderScTemplate(requestId, sessionId, timestamp) {
|
|
224
|
+
return `# SC Request ${requestId}
|
|
225
|
+
|
|
226
|
+
- session: ${sessionId}
|
|
227
|
+
- linked-prd: .peaks/${sessionId}/prd/requests/${requestId}.md
|
|
228
|
+
- linked-rd: .peaks/${sessionId}/rd/requests/${requestId}.md
|
|
229
|
+
- linked-qa: .peaks/${sessionId}/qa/requests/${requestId}.md
|
|
230
|
+
- linked-ui: .peaks/${sessionId}/ui/requests/${requestId}.md (when UI involved)
|
|
231
|
+
- type: feature | bug | refactor | clarification
|
|
232
|
+
|
|
233
|
+
## Change impact
|
|
234
|
+
|
|
235
|
+
- modules / files / routes / data models touched
|
|
236
|
+
- blast radius: local | cross-cutting | release-critical
|
|
237
|
+
- rollback strategy
|
|
238
|
+
|
|
239
|
+
## Commit boundaries
|
|
240
|
+
|
|
241
|
+
- one commit per OpenSpec heading (when openspec/ exists)
|
|
242
|
+
- otherwise: list of slice ids → commit message + scope
|
|
243
|
+
|
|
244
|
+
## Artifact retention
|
|
245
|
+
|
|
246
|
+
- prd artifact: ...
|
|
247
|
+
- rd artifact: ...
|
|
248
|
+
- qa artifact: ...
|
|
249
|
+
- ui artifact: ... (when UI involved)
|
|
250
|
+
- coverage evidence: ...
|
|
251
|
+
- code review evidence: ...
|
|
252
|
+
|
|
253
|
+
## Sync / authorization
|
|
254
|
+
|
|
255
|
+
- artifact workspace path: .peaks/${sessionId}/
|
|
256
|
+
- memory sync authorized: yes | no
|
|
257
|
+
- artifact sync authorized: yes | no
|
|
258
|
+
- rationale if not authorized: keep local
|
|
259
|
+
|
|
260
|
+
## Rollback points
|
|
261
|
+
|
|
262
|
+
- commits / tags / branches that can revert each boundary
|
|
263
|
+
|
|
264
|
+
## Handoff
|
|
265
|
+
|
|
266
|
+
- to peaks-txt: .peaks/${sessionId}/txt/skill-usage-lessons.md (when reusable lesson exists)
|
|
267
|
+
|
|
268
|
+
## Status
|
|
269
|
+
|
|
218
270
|
- created: ${timestamp}
|
|
219
271
|
- last update: ${timestamp}
|
|
220
272
|
- state: draft
|
|
@@ -230,11 +282,13 @@ function renderTemplate(role, requestId, sessionId, timestamp) {
|
|
|
230
282
|
return renderRdTemplate(requestId, sessionId, timestamp);
|
|
231
283
|
case 'qa':
|
|
232
284
|
return renderQaTemplate(requestId, sessionId, timestamp);
|
|
285
|
+
case 'sc':
|
|
286
|
+
return renderScTemplate(requestId, sessionId, timestamp);
|
|
233
287
|
}
|
|
234
288
|
}
|
|
235
289
|
export async function createRequestArtifact(options) {
|
|
236
290
|
if (!VALID_ROLES.has(options.role)) {
|
|
237
|
-
throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, or
|
|
291
|
+
throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, qa, or sc)`);
|
|
238
292
|
}
|
|
239
293
|
if (!REQUEST_ID_PATTERN.test(options.requestId)) {
|
|
240
294
|
throw new Error(`Invalid request id: ${options.requestId} (expected letters, digits, dots, underscores, or dashes)`);
|
|
@@ -313,7 +367,7 @@ export async function listRequestArtifacts(options) {
|
|
|
313
367
|
}
|
|
314
368
|
export async function showRequestArtifact(options) {
|
|
315
369
|
if (!VALID_ROLES.has(options.role)) {
|
|
316
|
-
throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, or
|
|
370
|
+
throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, qa, or sc)`);
|
|
317
371
|
}
|
|
318
372
|
if (!REQUEST_ID_PATTERN.test(options.requestId)) {
|
|
319
373
|
throw new Error(`Invalid request id: ${options.requestId} (expected letters, digits, dots, underscores, or dashes)`);
|
|
@@ -347,7 +401,8 @@ const ALLOWED_STATES_PER_ROLE = {
|
|
|
347
401
|
prd: ['draft', 'confirmed-by-user', 'handed-off', 'blocked'],
|
|
348
402
|
ui: ['draft', 'direction-locked', 'handed-off', 'blocked'],
|
|
349
403
|
rd: ['draft', 'spec-locked', 'implemented', 'qa-handoff', 'handed-off', 'blocked'],
|
|
350
|
-
qa: ['draft', 'running', 'verdict-issued', 'blocked']
|
|
404
|
+
qa: ['draft', 'running', 'verdict-issued', 'blocked'],
|
|
405
|
+
sc: ['draft', 'impact-recorded', 'boundary-recorded', 'handed-off', 'blocked']
|
|
351
406
|
};
|
|
352
407
|
export function allowedStatesForRole(role) {
|
|
353
408
|
return ALLOWED_STATES_PER_ROLE[role];
|
|
@@ -391,7 +446,7 @@ function updateStatusBlock(markdown, newState, timestamp, reason) {
|
|
|
391
446
|
}
|
|
392
447
|
export async function transitionRequestArtifact(options) {
|
|
393
448
|
if (!VALID_ROLES.has(options.role)) {
|
|
394
|
-
throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, or
|
|
449
|
+
throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, qa, or sc)`);
|
|
395
450
|
}
|
|
396
451
|
if (!REQUEST_ID_PATTERN.test(options.requestId)) {
|
|
397
452
|
throw new Error(`Invalid request id: ${options.requestId} (expected letters, digits, dots, underscores, or dashes)`);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function getUserConfigPath(): string;
|
|
2
|
+
export declare function isInsidePath(childPath: string, parentPath: string): boolean;
|
|
3
|
+
export declare function findProjectRoot(startPath: string): string | null;
|
|
4
|
+
export declare function resolveProjectRootForConfig(startPath: string): string;
|
|
5
|
+
export declare function getProjectConfigPath(projectRoot: string | null): string | null;
|
|
6
|
+
export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
|
|
7
|
+
export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
|
|
8
|
+
export declare function validateUserConfigPathForWrite(configPath: string): void;
|
|
9
|
+
export declare function validateArtifactWorkspaceRoot(artifactRoot: string, workspaceRoot: string): void;
|
|
10
|
+
export declare function validateArtifactWorkspaceMarkerPath(artifactRoot: string, peaksPath: string, markerPath: string): void;
|
|
11
|
+
export declare function readConfigFileSafely(configPath: string, errorMessage: string): string;
|
|
12
|
+
export declare function writeConfigFileSafely(configPath: string, content: string, validateBeforeWrite: () => void, errorMessage: string): void;
|
|
13
|
+
export declare function writeProjectConfigFile(projectRoot: string, configPath: string, content: string): void;
|
|
14
|
+
export declare function writeUserConfigFile(configPath: string, content: string): void;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
export function getUserConfigPath() {
|
|
6
|
+
return resolve(homedir(), '.peaks', 'config.json');
|
|
7
|
+
}
|
|
8
|
+
export function isInsidePath(childPath, parentPath) {
|
|
9
|
+
const relativePath = relative(parentPath, childPath);
|
|
10
|
+
return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath));
|
|
11
|
+
}
|
|
12
|
+
function isSafeProjectConfigMarker(projectRoot) {
|
|
13
|
+
const peaksPath = resolve(projectRoot, '.peaks');
|
|
14
|
+
const markerPath = resolve(peaksPath, 'config.json');
|
|
15
|
+
try {
|
|
16
|
+
const projectRootReal = realpathSync(projectRoot);
|
|
17
|
+
const peaksStats = lstatSync(peaksPath);
|
|
18
|
+
const peaksReal = realpathSync(peaksPath);
|
|
19
|
+
const markerStats = lstatSync(markerPath);
|
|
20
|
+
if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink())
|
|
21
|
+
return false;
|
|
22
|
+
if (!markerStats.isFile() || markerStats.isSymbolicLink() || markerStats.nlink !== 1)
|
|
23
|
+
return false;
|
|
24
|
+
const markerReal = realpathSync(markerPath);
|
|
25
|
+
if (!isInsidePath(peaksReal, projectRootReal))
|
|
26
|
+
return false;
|
|
27
|
+
if (!isInsidePath(markerReal, projectRootReal))
|
|
28
|
+
return false;
|
|
29
|
+
return isInsidePath(markerReal, peaksReal);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function normalizeBoundaryPath(path) {
|
|
36
|
+
const resolved = resolve(path);
|
|
37
|
+
let realPath = resolved;
|
|
38
|
+
try {
|
|
39
|
+
realPath = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
realPath = resolved;
|
|
43
|
+
}
|
|
44
|
+
return process.platform === 'win32' || process.platform === 'darwin' ? realPath.toLowerCase() : realPath;
|
|
45
|
+
}
|
|
46
|
+
function getHomeBoundaryPaths() {
|
|
47
|
+
return new Set([homedir(), process.env.HOME, process.env.USERPROFILE].filter((path) => typeof path === 'string' && path.length > 0).map(normalizeBoundaryPath));
|
|
48
|
+
}
|
|
49
|
+
export function findProjectRoot(startPath) {
|
|
50
|
+
const homeBoundaryPaths = getHomeBoundaryPaths();
|
|
51
|
+
let current = resolve(startPath);
|
|
52
|
+
let parent = dirname(current);
|
|
53
|
+
while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
|
|
54
|
+
if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
parent = current;
|
|
58
|
+
current = dirname(parent);
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
export function resolveProjectRootForConfig(startPath) {
|
|
63
|
+
const start = resolve(startPath);
|
|
64
|
+
const homeBoundaryPaths = getHomeBoundaryPaths();
|
|
65
|
+
let current = start;
|
|
66
|
+
let parent = dirname(current);
|
|
67
|
+
while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
|
|
68
|
+
if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
|
|
69
|
+
return current;
|
|
70
|
+
}
|
|
71
|
+
if (existsSync(resolve(current, 'package.json')) || existsSync(resolve(current, '.git'))) {
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
parent = current;
|
|
75
|
+
current = dirname(parent);
|
|
76
|
+
}
|
|
77
|
+
return start;
|
|
78
|
+
}
|
|
79
|
+
export function getProjectConfigPath(projectRoot) {
|
|
80
|
+
if (!projectRoot)
|
|
81
|
+
return null;
|
|
82
|
+
if (!isSafeProjectConfigMarker(projectRoot))
|
|
83
|
+
return null;
|
|
84
|
+
return resolve(projectRoot, '.peaks', 'config.json');
|
|
85
|
+
}
|
|
86
|
+
export function getProjectBootstrapConfigPath(projectRoot) {
|
|
87
|
+
const projectRootPath = resolve(projectRoot);
|
|
88
|
+
const peaksPath = resolve(projectRootPath, '.peaks');
|
|
89
|
+
const configPath = resolve(peaksPath, 'config.json');
|
|
90
|
+
if (!isInsidePath(configPath, projectRootPath)) {
|
|
91
|
+
throw new Error('Project config path must stay inside the project root');
|
|
92
|
+
}
|
|
93
|
+
if (!existsSync(peaksPath)) {
|
|
94
|
+
mkdirSync(peaksPath, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPath);
|
|
97
|
+
return configPath;
|
|
98
|
+
}
|
|
99
|
+
function validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPath) {
|
|
100
|
+
const projectRootReal = realpathSync(projectRootPath);
|
|
101
|
+
const peaksStats = lstatSync(peaksPath);
|
|
102
|
+
const peaksReal = realpathSync(peaksPath);
|
|
103
|
+
if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(projectRootReal, '.peaks')) {
|
|
104
|
+
throw new Error('Project config path must stay inside the project root');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const markerStats = lstatSync(configPath);
|
|
108
|
+
if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
|
|
109
|
+
throw new Error('Project config path must stay inside the project root');
|
|
110
|
+
}
|
|
111
|
+
if (markerStats.nlink !== 1) {
|
|
112
|
+
throw new Error('Config path must not be hardlinked');
|
|
113
|
+
}
|
|
114
|
+
const markerReal = realpathSync(configPath);
|
|
115
|
+
if (!isInsidePath(markerReal, projectRootReal) || !isInsidePath(markerReal, peaksReal)) {
|
|
116
|
+
throw new Error('Project config path must stay inside the project root');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error.code !== 'ENOENT') {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function validateProjectBootstrapConfigPathForWrite(projectRoot, configPath) {
|
|
126
|
+
const projectRootPath = resolve(projectRoot);
|
|
127
|
+
validateProjectBootstrapConfigPath(projectRootPath, resolve(projectRootPath, '.peaks'), configPath);
|
|
128
|
+
}
|
|
129
|
+
export function validateUserConfigPathForWrite(configPath) {
|
|
130
|
+
const userRoot = resolve(homedir());
|
|
131
|
+
const peaksPath = resolve(userRoot, '.peaks');
|
|
132
|
+
const userRootReal = realpathSync(userRoot);
|
|
133
|
+
const peaksStats = lstatSync(peaksPath);
|
|
134
|
+
const peaksReal = realpathSync(peaksPath);
|
|
135
|
+
if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(userRootReal, '.peaks')) {
|
|
136
|
+
throw new Error('User config path must stay inside the user root');
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const markerStats = lstatSync(configPath);
|
|
140
|
+
if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
|
|
141
|
+
throw new Error('User config path must stay inside the user root');
|
|
142
|
+
}
|
|
143
|
+
if (markerStats.nlink !== 1) {
|
|
144
|
+
throw new Error('Config path must not be hardlinked');
|
|
145
|
+
}
|
|
146
|
+
const markerReal = realpathSync(configPath);
|
|
147
|
+
if (!isInsidePath(markerReal, userRootReal) || !isInsidePath(markerReal, peaksReal)) {
|
|
148
|
+
throw new Error('User config path must stay inside the user root');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code !== 'ENOENT') {
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function validateArtifactWorkspaceRoot(artifactRoot, workspaceRoot) {
|
|
158
|
+
const artifactStats = lstatSync(artifactRoot);
|
|
159
|
+
if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
|
|
160
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
161
|
+
}
|
|
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
|
+
}
|
|
168
|
+
export function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
|
|
169
|
+
const artifactStats = lstatSync(artifactRoot);
|
|
170
|
+
if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
|
|
171
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
172
|
+
}
|
|
173
|
+
const artifactRootReal = realpathSync(artifactRoot);
|
|
174
|
+
const peaksStats = lstatSync(peaksPath);
|
|
175
|
+
const peaksReal = realpathSync(peaksPath);
|
|
176
|
+
if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(artifactRootReal, '.peaks')) {
|
|
177
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const markerStats = lstatSync(markerPath);
|
|
181
|
+
if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
|
|
182
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
183
|
+
}
|
|
184
|
+
if (markerStats.nlink !== 1) {
|
|
185
|
+
throw new Error('Config path must not be hardlinked');
|
|
186
|
+
}
|
|
187
|
+
const markerReal = realpathSync(markerPath);
|
|
188
|
+
if (!isInsidePath(markerReal, artifactRootReal) || !isInsidePath(markerReal, peaksReal)) {
|
|
189
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error.code !== 'ENOENT') {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function validateOpenConfigFile(fd, tempPath, errorMessage) {
|
|
199
|
+
const fdStats = fstatSync(fd);
|
|
200
|
+
const pathStats = lstatSync(tempPath);
|
|
201
|
+
if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
|
|
202
|
+
throw new Error(errorMessage);
|
|
203
|
+
}
|
|
204
|
+
if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
|
|
205
|
+
throw new Error('Config path must not be hardlinked');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function getSafeTempOpenFlags() {
|
|
209
|
+
const baseFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
|
|
210
|
+
return typeof constants.O_NOFOLLOW === 'number' ? baseFlags | constants.O_NOFOLLOW : baseFlags;
|
|
211
|
+
}
|
|
212
|
+
function getSafeReadOpenFlags() {
|
|
213
|
+
return typeof constants.O_NOFOLLOW === 'number' ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
|
|
214
|
+
}
|
|
215
|
+
export function readConfigFileSafely(configPath, errorMessage) {
|
|
216
|
+
const fd = openSync(configPath, getSafeReadOpenFlags());
|
|
217
|
+
try {
|
|
218
|
+
validateOpenConfigFile(fd, configPath, errorMessage);
|
|
219
|
+
return readFileSync(fd, 'utf-8');
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
closeSync(fd);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export function writeConfigFileSafely(configPath, content, validateBeforeWrite, errorMessage) {
|
|
226
|
+
validateBeforeWrite();
|
|
227
|
+
const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
228
|
+
let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
|
|
229
|
+
let renamed = false;
|
|
230
|
+
let closeError;
|
|
231
|
+
try {
|
|
232
|
+
validateOpenConfigFile(fd, tempPath, errorMessage);
|
|
233
|
+
fchmodSync(fd, 0o600);
|
|
234
|
+
writeFileSync(fd, content, 'utf-8');
|
|
235
|
+
const writeFd = fd;
|
|
236
|
+
fd = null;
|
|
237
|
+
closeSync(writeFd);
|
|
238
|
+
validateBeforeWrite();
|
|
239
|
+
const readFd = openSync(tempPath, getSafeReadOpenFlags());
|
|
240
|
+
try {
|
|
241
|
+
validateOpenConfigFile(readFd, tempPath, errorMessage);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
closeSync(readFd);
|
|
245
|
+
}
|
|
246
|
+
renameSync(tempPath, configPath);
|
|
247
|
+
renamed = true;
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
if (fd !== null) {
|
|
251
|
+
try {
|
|
252
|
+
closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
closeError = error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
if (!renamed && existsSync(tempPath)) {
|
|
260
|
+
unlinkSync(tempPath);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
if (closeError) {
|
|
265
|
+
throw closeError;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export function writeProjectConfigFile(projectRoot, configPath, content) {
|
|
271
|
+
writeConfigFileSafely(configPath, content, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath), 'Project config path must stay inside the project root');
|
|
272
|
+
}
|
|
273
|
+
export function writeUserConfigFile(configPath, content) {
|
|
274
|
+
writeConfigFileSafely(configPath, content, () => validateUserConfigPathForWrite(configPath), 'User config path must stay inside the user root');
|
|
275
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConfigGetOptions, ConfigLayer, ConfigSetOptions, MiniMaxProviderConfig, PeaksConfig, TokenRef, WorkspaceConfig } from './config-types.js';
|
|
2
|
-
export
|
|
2
|
+
export { resolveProjectRootForConfig } from './config-safety.js';
|
|
3
3
|
export declare function isConfigLayer(value: string): value is ConfigLayer;
|
|
4
4
|
export declare function isSensitiveConfigPath(path: string): boolean;
|
|
5
5
|
export declare function containsSensitiveConfigValue(value: unknown): boolean;
|