peaks-cli 1.0.7 → 1.0.9
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/workflow-commands.js +18 -7
- package/dist/src/services/config/config-service.d.ts +4 -0
- package/dist/src/services/config/config-service.js +325 -39
- package/dist/src/services/config/config-types.js +2 -1
- package/dist/src/shared/path-utils.d.ts +1 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/scripts/install-skills.mjs +272 -44
package/bin/peaks.js
CHANGED
|
File without changes
|
|
@@ -4,18 +4,29 @@ import { createWorkflowRouterPlan, isSoloMode, isWorkflowMode } from '../../serv
|
|
|
4
4
|
import { createAutonomousWorkflowPlan } from '../../services/workflow/workflow-autonomous-service.js';
|
|
5
5
|
import { createRecommendationPlan } from '../../services/recommendations/recommendation-service.js';
|
|
6
6
|
import { createRefactorDryRun } from '../../services/refactor/refactor-service.js';
|
|
7
|
-
import { getCurrentWorkspaceConfig, readConfig } from '../../services/config/config-service.js';
|
|
7
|
+
import { ensureWorkspaceConfigForCurrentPath, getCurrentWorkspaceConfig, readConfig } from '../../services/config/config-service.js';
|
|
8
8
|
import { validateChangeIdOrThrow } from '../../shared/change-id.js';
|
|
9
9
|
import { getEconomyAwareExecutionModelId } from '../../services/config/model-routing.js';
|
|
10
10
|
import { getLocalArtifactPath } from '../../services/artifacts/workspace-service.js';
|
|
11
11
|
import { fail, ok } from '../../shared/result.js';
|
|
12
12
|
import { addJsonOption, failUnsupportedNonDryRun, getErrorMessage, isRecommendationWorkflow, printResult } from '../cli-helpers.js';
|
|
13
|
-
function
|
|
13
|
+
function getCurrentWorkspaceContext() {
|
|
14
14
|
const workspace = getCurrentWorkspaceConfig();
|
|
15
15
|
if (!workspace)
|
|
16
16
|
return {};
|
|
17
17
|
return { workspace, artifactWorkspacePath: getLocalArtifactPath(workspace) };
|
|
18
18
|
}
|
|
19
|
+
function getWorkflowWorkspaceContext() {
|
|
20
|
+
try {
|
|
21
|
+
const workspace = ensureWorkspaceConfigForCurrentPath() ?? getCurrentWorkspaceConfig();
|
|
22
|
+
if (!workspace)
|
|
23
|
+
return {};
|
|
24
|
+
return { workspace, artifactWorkspacePath: getLocalArtifactPath(workspace) };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
19
30
|
function parseMaxWorkers(io, command, value, asJson) {
|
|
20
31
|
const maxWorkers = Number(value);
|
|
21
32
|
if (!Number.isInteger(maxWorkers) || maxWorkers < 1) {
|
|
@@ -54,7 +65,7 @@ function runTechPlan(io, options) {
|
|
|
54
65
|
}
|
|
55
66
|
try {
|
|
56
67
|
validatePlanningInput(options.changeId, options.goal);
|
|
57
|
-
const workspaceContext =
|
|
68
|
+
const workspaceContext = getCurrentWorkspaceContext();
|
|
58
69
|
const plan = createTechPlan({
|
|
59
70
|
changeId: options.changeId,
|
|
60
71
|
goal: options.goal,
|
|
@@ -71,7 +82,7 @@ function runTechPlan(io, options) {
|
|
|
71
82
|
}
|
|
72
83
|
function runTechStatus(io, options) {
|
|
73
84
|
try {
|
|
74
|
-
const workspaceContext =
|
|
85
|
+
const workspaceContext = getCurrentWorkspaceContext();
|
|
75
86
|
printResult(io, ok('tech.status', getTechStatus({ changeId: options.changeId, ...workspaceContext })), options.json);
|
|
76
87
|
}
|
|
77
88
|
catch (error) {
|
|
@@ -97,7 +108,7 @@ function runWorkflowRoute(io, options) {
|
|
|
97
108
|
return;
|
|
98
109
|
try {
|
|
99
110
|
validatePlanningInput(options.changeId, options.goal);
|
|
100
|
-
const workspaceContext =
|
|
111
|
+
const workspaceContext = getWorkflowWorkspaceContext();
|
|
101
112
|
const plan = createWorkflowRouterPlan({
|
|
102
113
|
changeId: options.changeId,
|
|
103
114
|
goal: options.goal,
|
|
@@ -133,7 +144,7 @@ function runAutonomousWorkflow(io, options) {
|
|
|
133
144
|
return;
|
|
134
145
|
try {
|
|
135
146
|
validatePlanningInput(options.changeId, options.goal);
|
|
136
|
-
const workspaceContext =
|
|
147
|
+
const workspaceContext = getWorkflowWorkspaceContext();
|
|
137
148
|
const plan = createAutonomousWorkflowPlan({
|
|
138
149
|
changeId: options.changeId,
|
|
139
150
|
goal: options.goal,
|
|
@@ -166,7 +177,7 @@ function runSwarmPlan(io, options) {
|
|
|
166
177
|
return;
|
|
167
178
|
try {
|
|
168
179
|
validatePlanningInput(options.changeId, options.goal);
|
|
169
|
-
const workspaceContext =
|
|
180
|
+
const workspaceContext = getWorkflowWorkspaceContext();
|
|
170
181
|
const config = readConfig();
|
|
171
182
|
const plan = createRdSwarmPlan({
|
|
172
183
|
skill: 'rd',
|
|
@@ -28,4 +28,8 @@ export declare function addWorkspace(workspace: WorkspaceConfig, layer?: ConfigL
|
|
|
28
28
|
export declare function removeWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
|
|
29
29
|
export declare function setCurrentWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
|
|
30
30
|
export declare function getCurrentWorkspaceConfig(): WorkspaceConfig | null;
|
|
31
|
+
export declare function getWorkspaceConfigForPath(path?: string): WorkspaceConfig | null;
|
|
32
|
+
export declare function ensureWorkspaceConfigForPath(path?: string): WorkspaceConfig | null;
|
|
33
|
+
export declare function getWorkspaceConfigForCurrentPath(): WorkspaceConfig | null;
|
|
34
|
+
export declare function ensureWorkspaceConfigForCurrentPath(): WorkspaceConfig | null;
|
|
31
35
|
export type { TokenRef, WorkspaceConfig, PeaksConfig, ConfigLayer };
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
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 { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { DEFAULT_CONFIG } from './config-types.js';
|
|
6
|
+
import { stablePath } from '../../shared/path-utils.js';
|
|
5
7
|
function getUserConfigPath() {
|
|
6
8
|
return resolve(homedir(), '.peaks', 'config.json');
|
|
7
9
|
}
|
|
@@ -14,7 +16,13 @@ function isSafeProjectConfigMarker(projectRoot) {
|
|
|
14
16
|
const markerPath = resolve(peaksPath, 'config.json');
|
|
15
17
|
try {
|
|
16
18
|
const projectRootReal = realpathSync(projectRoot);
|
|
19
|
+
const peaksStats = lstatSync(peaksPath);
|
|
17
20
|
const peaksReal = realpathSync(peaksPath);
|
|
21
|
+
const markerStats = lstatSync(markerPath);
|
|
22
|
+
if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink())
|
|
23
|
+
return false;
|
|
24
|
+
if (!markerStats.isFile() || markerStats.isSymbolicLink() || markerStats.nlink !== 1)
|
|
25
|
+
return false;
|
|
18
26
|
const markerReal = realpathSync(markerPath);
|
|
19
27
|
if (!isInsidePath(peaksReal, projectRootReal))
|
|
20
28
|
return false;
|
|
@@ -26,10 +34,25 @@ function isSafeProjectConfigMarker(projectRoot) {
|
|
|
26
34
|
return false;
|
|
27
35
|
}
|
|
28
36
|
}
|
|
37
|
+
function normalizeBoundaryPath(path) {
|
|
38
|
+
const resolved = resolve(path);
|
|
39
|
+
let realPath = resolved;
|
|
40
|
+
try {
|
|
41
|
+
realPath = existsSync(resolved) ? realpathSync.native(resolved) : resolved;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
realPath = resolved;
|
|
45
|
+
}
|
|
46
|
+
return process.platform === 'win32' || process.platform === 'darwin' ? realPath.toLowerCase() : realPath;
|
|
47
|
+
}
|
|
48
|
+
function getHomeBoundaryPaths() {
|
|
49
|
+
return new Set([homedir(), process.env.HOME, process.env.USERPROFILE].filter((path) => typeof path === 'string' && path.length > 0).map(normalizeBoundaryPath));
|
|
50
|
+
}
|
|
29
51
|
function findProjectRoot(startPath) {
|
|
52
|
+
const homeBoundaryPaths = getHomeBoundaryPaths();
|
|
30
53
|
let current = resolve(startPath);
|
|
31
54
|
let parent = dirname(current);
|
|
32
|
-
while (current !== parent) {
|
|
55
|
+
while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
|
|
33
56
|
if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
|
|
34
57
|
return current;
|
|
35
58
|
}
|
|
@@ -40,10 +63,10 @@ function findProjectRoot(startPath) {
|
|
|
40
63
|
}
|
|
41
64
|
export function resolveProjectRootForConfig(startPath) {
|
|
42
65
|
const start = resolve(startPath);
|
|
43
|
-
const
|
|
66
|
+
const homeBoundaryPaths = getHomeBoundaryPaths();
|
|
44
67
|
let current = start;
|
|
45
68
|
let parent = dirname(current);
|
|
46
|
-
while (current !== parent && current
|
|
69
|
+
while (current !== parent && !homeBoundaryPaths.has(normalizeBoundaryPath(current))) {
|
|
47
70
|
if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
|
|
48
71
|
return current;
|
|
49
72
|
}
|
|
@@ -87,6 +110,9 @@ function validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPa
|
|
|
87
110
|
if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
|
|
88
111
|
throw new Error('Project config path must stay inside the project root');
|
|
89
112
|
}
|
|
113
|
+
if (markerStats.nlink !== 1) {
|
|
114
|
+
throw new Error('Config path must not be hardlinked');
|
|
115
|
+
}
|
|
90
116
|
const markerReal = realpathSync(configPath);
|
|
91
117
|
if (!isInsidePath(markerReal, projectRootReal) || !isInsidePath(markerReal, peaksReal)) {
|
|
92
118
|
throw new Error('Project config path must stay inside the project root');
|
|
@@ -102,26 +128,184 @@ function validateProjectBootstrapConfigPathForWrite(projectRoot, configPath) {
|
|
|
102
128
|
const projectRootPath = resolve(projectRoot);
|
|
103
129
|
validateProjectBootstrapConfigPath(projectRootPath, resolve(projectRootPath, '.peaks'), configPath);
|
|
104
130
|
}
|
|
105
|
-
function
|
|
131
|
+
function validateUserConfigPathForWrite(configPath) {
|
|
132
|
+
const userRoot = resolve(homedir());
|
|
133
|
+
const peaksPath = resolve(userRoot, '.peaks');
|
|
134
|
+
const userRootReal = realpathSync(userRoot);
|
|
135
|
+
const peaksStats = lstatSync(peaksPath);
|
|
136
|
+
const peaksReal = realpathSync(peaksPath);
|
|
137
|
+
if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(userRootReal, '.peaks')) {
|
|
138
|
+
throw new Error('User config path must stay inside the user root');
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const markerStats = lstatSync(configPath);
|
|
142
|
+
if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
|
|
143
|
+
throw new Error('User config path must stay inside the user root');
|
|
144
|
+
}
|
|
145
|
+
if (markerStats.nlink !== 1) {
|
|
146
|
+
throw new Error('Config path must not be hardlinked');
|
|
147
|
+
}
|
|
148
|
+
const markerReal = realpathSync(configPath);
|
|
149
|
+
if (!isInsidePath(markerReal, userRootReal) || !isInsidePath(markerReal, peaksReal)) {
|
|
150
|
+
throw new Error('User config path must stay inside the user root');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error.code !== 'ENOENT') {
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function validateArtifactWorkspaceRoot(artifactRoot, workspaceRoot) {
|
|
160
|
+
const artifactStats = lstatSync(artifactRoot);
|
|
161
|
+
if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
|
|
162
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
163
|
+
}
|
|
164
|
+
const artifactRootReal = realpathSync(artifactRoot);
|
|
165
|
+
const workspaceRootReal = realpathSync(workspaceRoot);
|
|
166
|
+
if (isInsidePath(artifactRootReal, workspaceRootReal)) {
|
|
167
|
+
throw new Error('Artifact workspace must stay outside the project root');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
|
|
171
|
+
const artifactStats = lstatSync(artifactRoot);
|
|
172
|
+
if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
|
|
173
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
174
|
+
}
|
|
175
|
+
const artifactRootReal = realpathSync(artifactRoot);
|
|
176
|
+
const peaksStats = lstatSync(peaksPath);
|
|
177
|
+
const peaksReal = realpathSync(peaksPath);
|
|
178
|
+
if (!peaksStats.isDirectory() || peaksStats.isSymbolicLink() || peaksReal !== resolve(artifactRootReal, '.peaks')) {
|
|
179
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const markerStats = lstatSync(markerPath);
|
|
183
|
+
if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
|
|
184
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
185
|
+
}
|
|
186
|
+
if (markerStats.nlink !== 1) {
|
|
187
|
+
throw new Error('Config path must not be hardlinked');
|
|
188
|
+
}
|
|
189
|
+
const markerReal = realpathSync(markerPath);
|
|
190
|
+
if (!isInsidePath(markerReal, artifactRootReal) || !isInsidePath(markerReal, peaksReal)) {
|
|
191
|
+
throw new Error('Artifact workspace marker must stay inside the artifact workspace');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error.code !== 'ENOENT') {
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function validateOpenConfigFile(fd, tempPath, errorMessage) {
|
|
201
|
+
const fdStats = fstatSync(fd);
|
|
202
|
+
const pathStats = lstatSync(tempPath);
|
|
203
|
+
if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
|
|
204
|
+
throw new Error(errorMessage);
|
|
205
|
+
}
|
|
206
|
+
if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
|
|
207
|
+
throw new Error('Config path must not be hardlinked');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function getSafeTempOpenFlags() {
|
|
211
|
+
const baseFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
|
|
212
|
+
return typeof constants.O_NOFOLLOW === 'number' ? baseFlags | constants.O_NOFOLLOW : baseFlags;
|
|
213
|
+
}
|
|
214
|
+
function getSafeReadOpenFlags() {
|
|
215
|
+
return typeof constants.O_NOFOLLOW === 'number' ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
|
|
216
|
+
}
|
|
217
|
+
function readConfigFileSafely(configPath, errorMessage) {
|
|
218
|
+
const fd = openSync(configPath, getSafeReadOpenFlags());
|
|
219
|
+
try {
|
|
220
|
+
validateOpenConfigFile(fd, configPath, errorMessage);
|
|
221
|
+
return readFileSync(fd, 'utf-8');
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
closeSync(fd);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function writeConfigFileSafely(configPath, content, validateBeforeWrite, errorMessage) {
|
|
228
|
+
validateBeforeWrite();
|
|
229
|
+
const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
230
|
+
let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
|
|
231
|
+
let renamed = false;
|
|
232
|
+
let closeError;
|
|
233
|
+
try {
|
|
234
|
+
validateOpenConfigFile(fd, tempPath, errorMessage);
|
|
235
|
+
fchmodSync(fd, 0o600);
|
|
236
|
+
writeFileSync(fd, content, 'utf-8');
|
|
237
|
+
const writeFd = fd;
|
|
238
|
+
fd = null;
|
|
239
|
+
closeSync(writeFd);
|
|
240
|
+
validateBeforeWrite();
|
|
241
|
+
const readFd = openSync(tempPath, getSafeReadOpenFlags());
|
|
242
|
+
try {
|
|
243
|
+
validateOpenConfigFile(readFd, tempPath, errorMessage);
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
closeSync(readFd);
|
|
247
|
+
}
|
|
248
|
+
renameSync(tempPath, configPath);
|
|
249
|
+
renamed = true;
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
if (fd !== null) {
|
|
253
|
+
try {
|
|
254
|
+
closeSync(fd);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
closeError = error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
if (!renamed && existsSync(tempPath)) {
|
|
262
|
+
unlinkSync(tempPath);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
if (closeError) {
|
|
267
|
+
throw closeError;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function writeProjectConfigFile(projectRoot, configPath, content) {
|
|
273
|
+
writeConfigFileSafely(configPath, content, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath), 'Project config path must stay inside the project root');
|
|
274
|
+
}
|
|
275
|
+
function writeUserConfigFile(configPath, content) {
|
|
276
|
+
writeConfigFileSafely(configPath, content, () => validateUserConfigPathForWrite(configPath), 'User config path must stay inside the user root');
|
|
277
|
+
}
|
|
278
|
+
function readJsonFile(path, validateBeforeRead, errorMessage = 'Config path must stay inside the config root') {
|
|
106
279
|
if (!path || !existsSync(path))
|
|
107
280
|
return null;
|
|
281
|
+
validateBeforeRead?.();
|
|
282
|
+
const content = readConfigFileSafely(path, errorMessage);
|
|
108
283
|
try {
|
|
109
|
-
return JSON.parse(
|
|
284
|
+
return JSON.parse(content);
|
|
110
285
|
}
|
|
111
286
|
catch {
|
|
112
287
|
return null;
|
|
113
288
|
}
|
|
114
289
|
}
|
|
115
|
-
function readExistingJsonFile(path, errorMessage) {
|
|
290
|
+
function readExistingJsonFile(path, errorMessage, validateBeforeRead) {
|
|
116
291
|
if (!existsSync(path))
|
|
117
292
|
return null;
|
|
293
|
+
validateBeforeRead?.();
|
|
118
294
|
try {
|
|
119
|
-
return JSON.parse(
|
|
295
|
+
return JSON.parse(readConfigFileSafely(path, errorMessage));
|
|
120
296
|
}
|
|
121
297
|
catch {
|
|
122
298
|
throw new Error(errorMessage);
|
|
123
299
|
}
|
|
124
300
|
}
|
|
301
|
+
function readUserJsonFile() {
|
|
302
|
+
const userPath = getUserConfigPath();
|
|
303
|
+
return readJsonFile(userPath, () => validateUserConfigPathForWrite(userPath), 'User config path must stay inside the user root');
|
|
304
|
+
}
|
|
305
|
+
function readProjectJsonFile(projectRoot) {
|
|
306
|
+
const projectPath = getProjectConfigPath(projectRoot);
|
|
307
|
+
return readJsonFile(projectPath, projectRoot && projectPath ? () => validateProjectBootstrapConfigPathForWrite(projectRoot, projectPath) : undefined, 'Project config path must stay inside the project root');
|
|
308
|
+
}
|
|
125
309
|
function ensureDir(dirPath) {
|
|
126
310
|
if (!existsSync(dirPath)) {
|
|
127
311
|
mkdirSync(dirPath, { recursive: true });
|
|
@@ -284,6 +468,10 @@ function isRecord(value) {
|
|
|
284
468
|
function isSafeConfigSegment(value) {
|
|
285
469
|
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !value.includes('..') && !value.endsWith('.');
|
|
286
470
|
}
|
|
471
|
+
function toSafeConfigSegment(value) {
|
|
472
|
+
const normalized = value.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').replace(/\.+$/g, '');
|
|
473
|
+
return isSafeConfigSegment(normalized) ? normalized : 'workspace';
|
|
474
|
+
}
|
|
287
475
|
function toArtifactRemoteRepoConfig(value) {
|
|
288
476
|
if (!isRecord(value) || (value.provider !== 'github' && value.provider !== 'gitlab') || typeof value.owner !== 'string' || typeof value.name !== 'string') {
|
|
289
477
|
return null;
|
|
@@ -327,6 +515,15 @@ function toWorkspaceConfig(value) {
|
|
|
327
515
|
function toWorkspaceConfigs(value) {
|
|
328
516
|
return Array.isArray(value) ? value.map(toWorkspaceConfig).filter((workspace) => workspace !== null) : [];
|
|
329
517
|
}
|
|
518
|
+
function mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces) {
|
|
519
|
+
const merged = new Map(userWorkspaces.map((workspace) => [workspace.workspaceId, workspace]));
|
|
520
|
+
for (const workspace of projectWorkspaces) {
|
|
521
|
+
if (!merged.has(workspace.workspaceId)) {
|
|
522
|
+
merged.set(workspace.workspaceId, workspace);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return [...merged.values()];
|
|
526
|
+
}
|
|
330
527
|
function toProviderModelConfig(value) {
|
|
331
528
|
if (!isRecord(value))
|
|
332
529
|
return {};
|
|
@@ -380,12 +577,13 @@ function toProxyConfig(value) {
|
|
|
380
577
|
return null;
|
|
381
578
|
return typeof value.httpProxy === 'string' && isValidProxyUrl(value.httpProxy) ? { httpProxy: value.httpProxy } : null;
|
|
382
579
|
}
|
|
383
|
-
function
|
|
384
|
-
const
|
|
385
|
-
|
|
580
|
+
function getProjectWriteTarget() {
|
|
581
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
582
|
+
const configPath = getProjectConfigPath(projectRoot);
|
|
583
|
+
if (!projectRoot || !configPath) {
|
|
386
584
|
throw new Error('Project config not found');
|
|
387
585
|
}
|
|
388
|
-
return
|
|
586
|
+
return { projectRoot, configPath };
|
|
389
587
|
}
|
|
390
588
|
export function containsSensitiveConfigValue(value) {
|
|
391
589
|
if (Array.isArray(value)) {
|
|
@@ -432,14 +630,14 @@ function createMiniMaxProviderStatus(config) {
|
|
|
432
630
|
};
|
|
433
631
|
}
|
|
434
632
|
export function getMiniMaxProviderConfig() {
|
|
435
|
-
return toMiniMaxProviderConfig(
|
|
633
|
+
return toMiniMaxProviderConfig(readUserJsonFile()?.providers?.minimax);
|
|
436
634
|
}
|
|
437
635
|
export function getMiniMaxProviderStatus() {
|
|
438
636
|
return createMiniMaxProviderStatus(getMiniMaxProviderConfig());
|
|
439
637
|
}
|
|
440
638
|
export function setMiniMaxProviderConfig(input) {
|
|
441
639
|
validateMiniMaxBaseUrl(input.baseUrl);
|
|
442
|
-
const userConfig =
|
|
640
|
+
const userConfig = readUserJsonFile() ?? {};
|
|
443
641
|
const existingProviders = toModelProviderConfig(userConfig.providers);
|
|
444
642
|
const providers = {
|
|
445
643
|
...existingProviders,
|
|
@@ -485,24 +683,23 @@ function toPeaksConfig(value) {
|
|
|
485
683
|
export function bootstrapProjectLanguageConfig(projectRoot, language) {
|
|
486
684
|
const inferredLanguage = inferHumanLanguage(language);
|
|
487
685
|
const projectPath = getProjectBootstrapConfigPath(projectRoot);
|
|
488
|
-
const existing = readExistingJsonFile(projectPath, 'Project config must contain valid JSON') ?? {};
|
|
686
|
+
const existing = readExistingJsonFile(projectPath, 'Project config must contain valid JSON', () => validateProjectBootstrapConfigPathForWrite(projectRoot, projectPath)) ?? {};
|
|
489
687
|
if (typeof existing.language === 'string' && existing.language.trim().length > 0) {
|
|
490
688
|
return;
|
|
491
689
|
}
|
|
492
|
-
|
|
493
|
-
writeFileSync(projectPath, JSON.stringify({ ...existing, language: inferredLanguage }, null, 2), 'utf-8');
|
|
690
|
+
writeProjectConfigFile(projectRoot, projectPath, JSON.stringify({ ...existing, language: inferredLanguage }, null, 2));
|
|
494
691
|
}
|
|
495
692
|
export function readConfig(projectRoot) {
|
|
496
693
|
const detectedRoot = projectRoot ?? findProjectRoot(process.cwd());
|
|
497
|
-
const
|
|
498
|
-
const
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
|
|
694
|
+
const userConfig = toPeaksConfig(readUserJsonFile());
|
|
695
|
+
const projectConfig = removeProjectSensitiveConfig(toPeaksConfig(readProjectJsonFile(detectedRoot)));
|
|
696
|
+
const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
|
|
697
|
+
const userWorkspaces = userConfig.workspaces ?? [];
|
|
502
698
|
return {
|
|
503
699
|
...DEFAULT_CONFIG,
|
|
504
700
|
...userConfig,
|
|
505
|
-
...projectConfigWithoutProxy
|
|
701
|
+
...projectConfigWithoutProxy,
|
|
702
|
+
workspaces: mergeWorkspaceConfigs(userWorkspaces, projectWorkspaces ?? [])
|
|
506
703
|
};
|
|
507
704
|
}
|
|
508
705
|
export function writeConfig(partial, layer = 'user') {
|
|
@@ -515,27 +712,33 @@ export function writeConfig(partial, layer = 'user') {
|
|
|
515
712
|
validateProviderConfig(partial);
|
|
516
713
|
validateProxyConfig(partial);
|
|
517
714
|
if (layer === 'project') {
|
|
518
|
-
const
|
|
519
|
-
ensureDir(dirname(
|
|
520
|
-
const existing = readJsonFile(
|
|
715
|
+
const { projectRoot, configPath } = getProjectWriteTarget();
|
|
716
|
+
ensureDir(dirname(configPath));
|
|
717
|
+
const existing = readJsonFile(configPath, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath)) ?? {};
|
|
521
718
|
const merged = { ...existing, ...partial };
|
|
522
|
-
|
|
719
|
+
writeProjectConfigFile(projectRoot, configPath, JSON.stringify(merged, null, 2));
|
|
523
720
|
return;
|
|
524
721
|
}
|
|
525
722
|
const userPath = getUserConfigPath();
|
|
526
723
|
ensureDir(dirname(userPath));
|
|
527
|
-
const
|
|
528
|
-
ensureDir(userPathDir);
|
|
529
|
-
const existing = readJsonFile(userPath) ?? {};
|
|
724
|
+
const existing = readJsonFile(userPath, () => validateUserConfigPathForWrite(userPath)) ?? {};
|
|
530
725
|
const merged = { ...existing, ...partial };
|
|
531
|
-
|
|
726
|
+
writeUserConfigFile(userPath, JSON.stringify(merged, null, 2));
|
|
532
727
|
}
|
|
533
728
|
export function getConfig(options = {}) {
|
|
534
729
|
const projectRoot = findProjectRoot(process.cwd());
|
|
535
|
-
const userConfig =
|
|
536
|
-
const projectConfig = removeProjectSensitiveConfig(
|
|
537
|
-
const { proxy: projectProxy, ...projectConfigWithoutProxy } = projectConfig;
|
|
538
|
-
const source = options.layer === 'user'
|
|
730
|
+
const userConfig = readUserJsonFile() ?? {};
|
|
731
|
+
const projectConfig = removeProjectSensitiveConfig(readProjectJsonFile(projectRoot) ?? {});
|
|
732
|
+
const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
|
|
733
|
+
const source = options.layer === 'user'
|
|
734
|
+
? userConfig
|
|
735
|
+
: options.layer === 'project'
|
|
736
|
+
? projectConfig
|
|
737
|
+
: {
|
|
738
|
+
...userConfig,
|
|
739
|
+
...projectConfigWithoutProxy,
|
|
740
|
+
workspaces: mergeWorkspaceConfigs(toWorkspaceConfigs(userConfig.workspaces), toWorkspaceConfigs(projectWorkspaces))
|
|
741
|
+
};
|
|
539
742
|
const config = isRecord(source) ? { ...source, ...(source.tokens !== undefined ? { tokens: toTokenConfig(source.tokens) } : {}) } : source;
|
|
540
743
|
if (options.key !== undefined) {
|
|
541
744
|
return getNestedValue(config, options.key);
|
|
@@ -564,12 +767,21 @@ export function setConfig(options) {
|
|
|
564
767
|
}
|
|
565
768
|
}
|
|
566
769
|
validateProxyUrl(getProxyUrlCandidate(options.key, options.value));
|
|
567
|
-
const
|
|
770
|
+
const projectTarget = layer === 'project' ? getProjectWriteTarget() : null;
|
|
771
|
+
const targetPath = projectTarget?.configPath ?? getUserConfigPath();
|
|
568
772
|
ensureDir(dirname(targetPath));
|
|
569
|
-
const existing =
|
|
773
|
+
const existing = projectTarget
|
|
774
|
+
? readJsonFile(targetPath, () => validateProjectBootstrapConfigPathForWrite(projectTarget.projectRoot, targetPath)) ?? {}
|
|
775
|
+
: readJsonFile(targetPath, () => validateUserConfigPathForWrite(targetPath)) ?? {};
|
|
570
776
|
const updated = { ...existing };
|
|
571
777
|
setNestedValue(updated, options.key, options.value);
|
|
572
|
-
|
|
778
|
+
const content = JSON.stringify(updated, null, 2);
|
|
779
|
+
if (projectTarget) {
|
|
780
|
+
writeProjectConfigFile(projectTarget.projectRoot, targetPath, content);
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
writeUserConfigFile(targetPath, content);
|
|
784
|
+
}
|
|
573
785
|
}
|
|
574
786
|
export function getWorkspaceConfig(workspaceId, projectRoot) {
|
|
575
787
|
const config = readConfig(projectRoot ?? findProjectRoot(process.cwd()));
|
|
@@ -626,3 +838,77 @@ export function getCurrentWorkspaceConfig() {
|
|
|
626
838
|
return null;
|
|
627
839
|
return getWorkspaceConfig(config.currentWorkspace);
|
|
628
840
|
}
|
|
841
|
+
export function getWorkspaceConfigForPath(path = process.cwd()) {
|
|
842
|
+
const config = readConfig(findProjectRoot(path));
|
|
843
|
+
return findWorkspaceForPath(config.workspaces, path);
|
|
844
|
+
}
|
|
845
|
+
function createWorkspaceId(projectRoot, existingIds) {
|
|
846
|
+
const base = toSafeConfigSegment(basename(projectRoot));
|
|
847
|
+
if (!existingIds.has(base))
|
|
848
|
+
return base;
|
|
849
|
+
let suffix = 2;
|
|
850
|
+
while (existingIds.has(`${base}-${suffix}`)) {
|
|
851
|
+
suffix += 1;
|
|
852
|
+
}
|
|
853
|
+
return `${base}-${suffix}`;
|
|
854
|
+
}
|
|
855
|
+
function findWorkspaceForPath(workspaces, path) {
|
|
856
|
+
const targetPath = stablePath(path);
|
|
857
|
+
const matches = workspaces.flatMap((workspace) => {
|
|
858
|
+
if (!isAbsolute(workspace.rootPath) || !existsSync(workspace.rootPath))
|
|
859
|
+
return [];
|
|
860
|
+
const rootPath = stablePath(workspace.rootPath);
|
|
861
|
+
return isInsidePath(targetPath, rootPath) ? [{ workspace, rootPath }] : [];
|
|
862
|
+
});
|
|
863
|
+
if (matches.length === 0)
|
|
864
|
+
return null;
|
|
865
|
+
return matches.reduce((best, match) => match.rootPath.length > best.rootPath.length ? match : best).workspace;
|
|
866
|
+
}
|
|
867
|
+
function getWorkspaceArtifactRoot(workspace) {
|
|
868
|
+
return workspace.artifactStorage?.localPath ? resolve(workspace.artifactStorage.localPath) : resolve(homedir(), '.peaks', 'workspaces', workspace.workspaceId, 'artifacts');
|
|
869
|
+
}
|
|
870
|
+
function ensureArtifactWorkspaceMarker(workspace) {
|
|
871
|
+
const artifactRoot = getWorkspaceArtifactRoot(workspace);
|
|
872
|
+
const peaksPath = resolve(artifactRoot, '.peaks');
|
|
873
|
+
const markerPath = resolve(peaksPath, 'config.json');
|
|
874
|
+
ensureDir(artifactRoot);
|
|
875
|
+
validateArtifactWorkspaceRoot(artifactRoot, workspace.rootPath);
|
|
876
|
+
ensureDir(peaksPath);
|
|
877
|
+
validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath);
|
|
878
|
+
if (!existsSync(markerPath)) {
|
|
879
|
+
writeConfigFileSafely(markerPath, '{}\n', () => validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath), 'Artifact workspace marker must stay inside the artifact workspace');
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
export function ensureWorkspaceConfigForPath(path = process.cwd()) {
|
|
883
|
+
const projectRoot = resolveProjectRootForConfig(path);
|
|
884
|
+
if (!isAbsolute(projectRoot) || !existsSync(projectRoot))
|
|
885
|
+
return null;
|
|
886
|
+
const config = readLayerConfig('user');
|
|
887
|
+
const existingWorkspace = findWorkspaceForPath(config.workspaces, path);
|
|
888
|
+
if (existingWorkspace) {
|
|
889
|
+
ensureArtifactWorkspaceMarker(existingWorkspace);
|
|
890
|
+
if (!config.currentWorkspace) {
|
|
891
|
+
writeConfig({ currentWorkspace: existingWorkspace.workspaceId }, 'user');
|
|
892
|
+
}
|
|
893
|
+
return existingWorkspace;
|
|
894
|
+
}
|
|
895
|
+
const existingIds = new Set(config.workspaces.map((workspace) => workspace.workspaceId));
|
|
896
|
+
const workspaceId = createWorkspaceId(projectRoot, existingIds);
|
|
897
|
+
const workspace = {
|
|
898
|
+
workspaceId,
|
|
899
|
+
name: basename(projectRoot) || 'Workspace',
|
|
900
|
+
rootPath: stablePath(projectRoot),
|
|
901
|
+
artifactStorage: { mode: 'local', localPath: resolve(homedir(), '.peaks', 'workspaces', workspaceId, 'artifacts') },
|
|
902
|
+
installedCapabilityIds: []
|
|
903
|
+
};
|
|
904
|
+
ensureArtifactWorkspaceMarker(workspace);
|
|
905
|
+
const updatedWorkspaces = [...config.workspaces, workspace];
|
|
906
|
+
writeConfig({ workspaces: updatedWorkspaces, ...(!config.currentWorkspace ? { currentWorkspace: workspace.workspaceId } : {}) }, 'user');
|
|
907
|
+
return workspace;
|
|
908
|
+
}
|
|
909
|
+
export function getWorkspaceConfigForCurrentPath() {
|
|
910
|
+
return getWorkspaceConfigForPath(process.cwd());
|
|
911
|
+
}
|
|
912
|
+
export function ensureWorkspaceConfigForCurrentPath() {
|
|
913
|
+
return ensureWorkspaceConfigForPath(process.cwd());
|
|
914
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Platform } from './platform.js';
|
|
2
|
-
export declare const SEP: "
|
|
2
|
+
export declare const SEP: "\\" | "/";
|
|
3
3
|
export declare function normalizePath(p: string): string;
|
|
4
4
|
export declare function pathsEqual(a: string, b: string): boolean;
|
|
5
5
|
export declare function localPath(p: string, targetPlatform?: Platform): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.0.
|
|
1
|
+
export declare const CLI_VERSION = "1.0.9";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.0.
|
|
1
|
+
export const CLI_VERSION = "1.0.9";
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { closeSync, constants,
|
|
2
|
+
import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
7
|
|
|
7
8
|
function getPathStats(path) {
|
|
@@ -16,31 +17,163 @@ function isBrokenSymlink(stats, targetPath) {
|
|
|
16
17
|
return stats.isSymbolicLink() && !existsSync(targetPath);
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function validateManagedMarkerPath(markerPath) {
|
|
21
|
+
const markerStats = getPathStats(markerPath);
|
|
22
|
+
if (!markerStats) return;
|
|
23
|
+
if (markerStats.isSymbolicLink()) {
|
|
24
|
+
throw new Error('Peaks managed marker path must not be a symlink');
|
|
25
|
+
}
|
|
26
|
+
if (!markerStats.isFile()) {
|
|
27
|
+
throw new Error('Peaks managed marker path must be a file');
|
|
28
|
+
}
|
|
29
|
+
if (markerStats.nlink !== 1) {
|
|
30
|
+
throw new Error('Peaks managed marker path must not be hardlinked');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validateOpenFile(fd, path, errorMessage) {
|
|
35
|
+
const fdStats = fstatSync(fd);
|
|
36
|
+
const pathStats = lstatSync(path);
|
|
37
|
+
if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
|
|
38
|
+
throw new Error(errorMessage);
|
|
39
|
+
}
|
|
40
|
+
if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
|
|
41
|
+
throw new Error(`${errorMessage}: hardlinked file`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createFileIdentity(path) {
|
|
46
|
+
const stats = lstatSync(path);
|
|
47
|
+
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink !== 1) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isSameFileIdentity(path, identity) {
|
|
54
|
+
if (identity === null) return false;
|
|
55
|
+
const stats = getPathStats(path);
|
|
56
|
+
return Boolean(stats?.isFile() && !stats.isSymbolicLink() && stats.nlink === 1 && stats.dev === identity.dev && stats.ino === identity.ino);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getSafeReadOpenFlags() {
|
|
60
|
+
return typeof constants.O_NOFOLLOW === 'number' ? constants.O_RDONLY | constants.O_NOFOLLOW : constants.O_RDONLY;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readFileSafely(path, errorMessage) {
|
|
64
|
+
const fd = openSync(path, getSafeReadOpenFlags());
|
|
65
|
+
try {
|
|
66
|
+
validateOpenFile(fd, path, errorMessage);
|
|
67
|
+
return readFileSync(fd, 'utf8');
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
19
73
|
function getManagedTarget(targetPath) {
|
|
20
74
|
const markerPath = `${targetPath}.peaks-managed`;
|
|
75
|
+
validateManagedMarkerPath(markerPath);
|
|
21
76
|
if (!existsSync(markerPath)) {
|
|
22
77
|
return null;
|
|
23
78
|
}
|
|
24
|
-
return
|
|
79
|
+
return readFileSafely(markerPath, 'Peaks managed marker path changed during read').trim();
|
|
25
80
|
}
|
|
26
81
|
|
|
27
82
|
function markManagedPeaksLink(targetPath, sourcePath) {
|
|
28
83
|
const markerPath = `${targetPath}.peaks-managed`;
|
|
29
|
-
|
|
84
|
+
validateManagedMarkerPath(markerPath);
|
|
85
|
+
writeFileAtomically(markerPath, `${sourcePath}\n`, 'Peaks managed marker path changed during write', () => validateManagedMarkerPath(markerPath));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readPackageSourceFile(path) {
|
|
89
|
+
const stats = lstatSync(path);
|
|
90
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
91
|
+
throw new Error('Peaks package source path must be a file');
|
|
92
|
+
}
|
|
93
|
+
return readFileSync(path, 'utf8');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hashContent(content) {
|
|
97
|
+
return createHash('sha256').update(content).digest('hex');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hashFileContent(path) {
|
|
101
|
+
return hashContent(readFileSafely(path, 'Peaks managed file path changed during read'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createManagedOutputStyleMarker(sourcePath, outputStyleName) {
|
|
105
|
+
const content = readPackageSourceFile(sourcePath);
|
|
106
|
+
return `${JSON.stringify({ version: 1, kind: 'output-style', outputStyleName, sourcePath, contentSha256: hashContent(content) })}\n`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseManagedOutputStyleMarker(managedTarget) {
|
|
110
|
+
if (managedTarget === null) return null;
|
|
111
|
+
try {
|
|
112
|
+
const marker = JSON.parse(managedTarget);
|
|
113
|
+
if (marker?.version !== 1 || marker?.kind !== 'output-style' || typeof marker.outputStyleName !== 'string' || typeof marker.sourcePath !== 'string' || typeof marker.contentSha256 !== 'string') {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return marker;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isTrustedOutputStyleSource(marker, sourcePath, outputStyleName) {
|
|
123
|
+
return marker.outputStyleName === outputStyleName && resolve(marker.sourcePath) === resolve(sourcePath) && basename(resolve(marker.sourcePath)) === outputStyleName;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getManagedPeaksOutputStyleIdentity(managedTarget, targetPath, sourcePath, outputStyleName) {
|
|
127
|
+
const marker = parseManagedOutputStyleMarker(managedTarget);
|
|
128
|
+
const sourceHash = hashContent(readPackageSourceFile(sourcePath));
|
|
129
|
+
if (marker === null || !isTrustedOutputStyleSource(marker, sourcePath, outputStyleName) || !existsSync(targetPath) || hashFileContent(targetPath) !== sourceHash || marker.contentSha256 !== sourceHash) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return createFileIdentity(targetPath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateInstallRoot(targetRoot, label) {
|
|
136
|
+
const rootStats = lstatSync(targetRoot);
|
|
137
|
+
if (rootStats.isSymbolicLink()) {
|
|
138
|
+
throw new Error(`${label} install root must not be a symlink`);
|
|
139
|
+
}
|
|
140
|
+
if (!rootStats.isDirectory()) {
|
|
141
|
+
throw new Error(`${label} install root must be a directory`);
|
|
142
|
+
}
|
|
143
|
+
return rootStats;
|
|
30
144
|
}
|
|
31
145
|
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
return
|
|
146
|
+
function createInstallRootValidator(targetRoot, label) {
|
|
147
|
+
const expectedStats = validateInstallRoot(targetRoot, label);
|
|
148
|
+
return () => {
|
|
149
|
+
const rootStats = validateInstallRoot(targetRoot, label);
|
|
150
|
+
if (rootStats.dev !== expectedStats.dev || rootStats.ino !== expectedStats.ino) {
|
|
151
|
+
throw new Error(`${label} install root changed during write`);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
35
154
|
}
|
|
36
155
|
|
|
37
156
|
function createInstallResult() {
|
|
38
157
|
return { installed: [], skipped: [] };
|
|
39
158
|
}
|
|
40
159
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
160
|
+
function resolvePackageRoot(options = {}) {
|
|
161
|
+
return resolve(options.packageRoot ?? join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readPackageVersion(packageRoot = resolvePackageRoot()) {
|
|
165
|
+
const packageJson = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
|
|
166
|
+
if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) {
|
|
167
|
+
throw new Error('package.json version must be a non-empty string');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return packageJson.version;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function createConfigDefaults(packageRoot) {
|
|
174
|
+
return {
|
|
175
|
+
version: readPackageVersion(packageRoot),
|
|
176
|
+
currentWorkspace: null,
|
|
44
177
|
workspaces: [],
|
|
45
178
|
language: 'en',
|
|
46
179
|
model: 'sonnet',
|
|
@@ -52,8 +185,9 @@ const PROJECT_CONFIG_DEFAULTS = {
|
|
|
52
185
|
model: 'minimax-2.7'
|
|
53
186
|
}
|
|
54
187
|
},
|
|
55
|
-
|
|
56
|
-
};
|
|
188
|
+
proxy: {}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
57
191
|
|
|
58
192
|
function createConfigResult(overrides = {}) {
|
|
59
193
|
return { created: false, updated: false, skipped: false, ...overrides };
|
|
@@ -89,7 +223,7 @@ function readConfigFile(configPath, label) {
|
|
|
89
223
|
}
|
|
90
224
|
|
|
91
225
|
try {
|
|
92
|
-
const parsed = JSON.parse(
|
|
226
|
+
const parsed = JSON.parse(readFileSafely(configPath, `${label} config path changed during read`));
|
|
93
227
|
if (!isPlainObject(parsed)) {
|
|
94
228
|
throw new Error(`${label} config must contain a JSON object`);
|
|
95
229
|
}
|
|
@@ -117,6 +251,9 @@ function validateConfigPath(root, peaksRoot, configPath, label) {
|
|
|
117
251
|
throw new Error(`${label} config path must be a file`);
|
|
118
252
|
}
|
|
119
253
|
if (configStats) {
|
|
254
|
+
if (configStats.nlink !== 1) {
|
|
255
|
+
throw new Error(`${label} config path must not be hardlinked`);
|
|
256
|
+
}
|
|
120
257
|
const configReal = realpathSync(configPath);
|
|
121
258
|
if (!isInsidePath(configReal, rootReal) || !isInsidePath(configReal, peaksReal)) {
|
|
122
259
|
throw new Error(`${label} config path must stay inside the ${label.toLowerCase()} root`);
|
|
@@ -132,41 +269,93 @@ function validateUserConfigPaths(userRoot, peaksRoot, configPath) {
|
|
|
132
269
|
validateConfigPath(userRoot, peaksRoot, configPath, 'User');
|
|
133
270
|
}
|
|
134
271
|
|
|
135
|
-
function
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
|
|
139
|
-
throw new Error(`${label} config path changed during write`);
|
|
140
|
-
}
|
|
141
|
-
if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
|
|
142
|
-
throw new Error(`${label} config path must not be hardlinked`);
|
|
143
|
-
}
|
|
272
|
+
function getSafeTempOpenFlags() {
|
|
273
|
+
const baseFlags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL;
|
|
274
|
+
return typeof constants.O_NOFOLLOW === 'number' ? baseFlags | constants.O_NOFOLLOW : baseFlags;
|
|
144
275
|
}
|
|
145
276
|
|
|
146
|
-
function
|
|
277
|
+
function writeFileExclusively(path, content, errorMessage, validateBeforeWrite) {
|
|
147
278
|
validateBeforeWrite();
|
|
148
|
-
|
|
149
|
-
|
|
279
|
+
let fd = openSync(path, getSafeTempOpenFlags(), 0o600);
|
|
280
|
+
let closeError = null;
|
|
281
|
+
let identity = null;
|
|
282
|
+
try {
|
|
283
|
+
validateOpenFile(fd, path, errorMessage);
|
|
284
|
+
validateBeforeWrite();
|
|
285
|
+
validateOpenFile(fd, path, errorMessage);
|
|
286
|
+
identity = createFileIdentity(path);
|
|
287
|
+
if (identity === null) {
|
|
288
|
+
throw new Error(errorMessage);
|
|
289
|
+
}
|
|
290
|
+
fchmodSync(fd, 0o600);
|
|
291
|
+
writeFileSync(fd, content, 'utf8');
|
|
292
|
+
const writeFd = fd;
|
|
293
|
+
fd = null;
|
|
294
|
+
closeSync(writeFd);
|
|
295
|
+
return identity;
|
|
296
|
+
} finally {
|
|
297
|
+
if (fd !== null) {
|
|
298
|
+
try {
|
|
299
|
+
closeSync(fd);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
closeError = error;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (closeError) {
|
|
305
|
+
throw closeError;
|
|
306
|
+
}
|
|
150
307
|
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function writeFileAtomically(configPath, content, errorMessage, validateBeforeWrite) {
|
|
311
|
+
validateBeforeWrite();
|
|
151
312
|
|
|
152
|
-
const
|
|
313
|
+
const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
314
|
+
let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
|
|
315
|
+
let renamed = false;
|
|
316
|
+
let closeError = null;
|
|
153
317
|
try {
|
|
154
|
-
|
|
155
|
-
validateOpenConfigFile(fd, configPath, label);
|
|
318
|
+
validateOpenFile(fd, tempPath, errorMessage);
|
|
156
319
|
fchmodSync(fd, 0o600);
|
|
157
|
-
ftruncateSync(fd, 0);
|
|
158
320
|
writeFileSync(fd, content, 'utf8');
|
|
321
|
+
const writeFd = fd;
|
|
322
|
+
fd = null;
|
|
323
|
+
closeSync(writeFd);
|
|
324
|
+
validateBeforeWrite();
|
|
325
|
+
const readFd = openSync(tempPath, getSafeReadOpenFlags());
|
|
326
|
+
try {
|
|
327
|
+
validateOpenFile(readFd, tempPath, errorMessage);
|
|
328
|
+
} finally {
|
|
329
|
+
closeSync(readFd);
|
|
330
|
+
}
|
|
331
|
+
renameSync(tempPath, configPath);
|
|
332
|
+
renamed = true;
|
|
159
333
|
} finally {
|
|
160
|
-
|
|
334
|
+
if (fd !== null) {
|
|
335
|
+
try {
|
|
336
|
+
closeSync(fd);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
closeError = error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
if (!renamed && existsSync(tempPath)) {
|
|
343
|
+
unlinkSync(tempPath);
|
|
344
|
+
}
|
|
345
|
+
} finally {
|
|
346
|
+
if (closeError) {
|
|
347
|
+
throw closeError;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
161
350
|
}
|
|
162
351
|
}
|
|
163
352
|
|
|
164
353
|
function writeProjectConfig(projectRoot, peaksRoot, configPath, content) {
|
|
165
|
-
|
|
354
|
+
writeFileAtomically(configPath, content, 'Project config path changed during write', () => validateProjectConfigPaths(projectRoot, peaksRoot, configPath));
|
|
166
355
|
}
|
|
167
356
|
|
|
168
357
|
function writeUserConfig(userRoot, peaksRoot, configPath, content) {
|
|
169
|
-
|
|
358
|
+
writeFileAtomically(configPath, content, 'User config path changed during write', () => validateUserConfigPaths(userRoot, peaksRoot, configPath));
|
|
170
359
|
}
|
|
171
360
|
|
|
172
361
|
function resolveProjectRoot(options) {
|
|
@@ -174,9 +363,9 @@ function resolveProjectRoot(options) {
|
|
|
174
363
|
return projectRoot ? resolve(projectRoot) : null;
|
|
175
364
|
}
|
|
176
365
|
|
|
177
|
-
function writeMergedConfig(configPath, label, writeConfig) {
|
|
366
|
+
function writeMergedConfig(configPath, label, defaults, writeConfig) {
|
|
178
367
|
const existing = readConfigFile(configPath, label);
|
|
179
|
-
const next = existing === null ?
|
|
368
|
+
const next = { ...(existing === null ? defaults : mergeMissingConfigValues(existing, defaults)), version: defaults.version };
|
|
180
369
|
const currentJson = existing === null ? null : `${JSON.stringify(existing, null, 2)}\n`;
|
|
181
370
|
const nextJson = `${JSON.stringify(next, null, 2)}\n`;
|
|
182
371
|
|
|
@@ -205,7 +394,7 @@ export function installUserConfig(options = {}) {
|
|
|
205
394
|
}
|
|
206
395
|
validateUserConfigPaths(userRoot, peaksRoot, configPath);
|
|
207
396
|
|
|
208
|
-
return writeMergedConfig(configPath, 'User', (content) => writeUserConfig(userRoot, peaksRoot, configPath, content));
|
|
397
|
+
return writeMergedConfig(configPath, 'User', createConfigDefaults(options.packageRoot), (content) => writeUserConfig(userRoot, peaksRoot, configPath, content));
|
|
209
398
|
}
|
|
210
399
|
|
|
211
400
|
export function installProjectConfig(options = {}) {
|
|
@@ -229,11 +418,11 @@ export function installProjectConfig(options = {}) {
|
|
|
229
418
|
}
|
|
230
419
|
validateProjectConfigPaths(projectRoot, peaksRoot, configPath);
|
|
231
420
|
|
|
232
|
-
return writeMergedConfig(configPath, 'Project', (content) => writeProjectConfig(projectRoot, peaksRoot, configPath, content));
|
|
421
|
+
return writeMergedConfig(configPath, 'Project', createConfigDefaults(options.packageRoot), (content) => writeProjectConfig(projectRoot, peaksRoot, configPath, content));
|
|
233
422
|
}
|
|
234
423
|
|
|
235
424
|
export function installBundledSkills(options = {}) {
|
|
236
|
-
const packageRoot =
|
|
425
|
+
const packageRoot = resolvePackageRoot(options);
|
|
237
426
|
const skillsRoot = join(packageRoot, 'skills');
|
|
238
427
|
const targetRoot = resolve(options.targetRoot ?? process.env.PEAKS_CLAUDE_SKILLS_DIR ?? join(homedir(), '.claude', 'skills'));
|
|
239
428
|
|
|
@@ -244,6 +433,7 @@ export function installBundledSkills(options = {}) {
|
|
|
244
433
|
const installed = [];
|
|
245
434
|
const skipped = [];
|
|
246
435
|
mkdirSync(targetRoot, { recursive: true });
|
|
436
|
+
const validateSkillsRoot = createInstallRootValidator(targetRoot, 'Peaks skills');
|
|
247
437
|
|
|
248
438
|
for (const skillName of readdirSync(skillsRoot)) {
|
|
249
439
|
const sourcePath = join(skillsRoot, skillName);
|
|
@@ -257,12 +447,15 @@ export function installBundledSkills(options = {}) {
|
|
|
257
447
|
const current = getPathStats(targetPath);
|
|
258
448
|
if (current) {
|
|
259
449
|
const managedTarget = getManagedTarget(targetPath);
|
|
260
|
-
|
|
450
|
+
const linkTarget = current.isSymbolicLink() ? readlinkSync(targetPath) : null;
|
|
451
|
+
if (linkTarget === sourcePath) {
|
|
261
452
|
installed.push(skillName);
|
|
262
453
|
continue;
|
|
263
454
|
}
|
|
264
|
-
if (isBrokenSymlink(current, targetPath) && managedTarget ===
|
|
455
|
+
if ((current.isSymbolicLink() || isBrokenSymlink(current, targetPath)) && managedTarget === linkTarget) {
|
|
456
|
+
validateSkillsRoot();
|
|
265
457
|
unlinkSync(targetPath);
|
|
458
|
+
validateSkillsRoot();
|
|
266
459
|
unlinkSync(`${targetPath}.peaks-managed`);
|
|
267
460
|
} else {
|
|
268
461
|
skipped.push(skillName);
|
|
@@ -270,8 +463,19 @@ export function installBundledSkills(options = {}) {
|
|
|
270
463
|
}
|
|
271
464
|
}
|
|
272
465
|
|
|
466
|
+
validateSkillsRoot();
|
|
273
467
|
symlinkSync(sourcePath, targetPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
274
|
-
|
|
468
|
+
try {
|
|
469
|
+
validateSkillsRoot();
|
|
470
|
+
markManagedPeaksLink(targetPath, sourcePath);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
validateSkillsRoot();
|
|
473
|
+
const created = getPathStats(targetPath);
|
|
474
|
+
if (created?.isSymbolicLink() && readlinkSync(targetPath) === sourcePath) {
|
|
475
|
+
unlinkSync(targetPath);
|
|
476
|
+
}
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
275
479
|
installed.push(skillName);
|
|
276
480
|
}
|
|
277
481
|
|
|
@@ -279,7 +483,7 @@ export function installBundledSkills(options = {}) {
|
|
|
279
483
|
}
|
|
280
484
|
|
|
281
485
|
export function installBundledOutputStyles(options = {}) {
|
|
282
|
-
const packageRoot =
|
|
486
|
+
const packageRoot = resolvePackageRoot(options);
|
|
283
487
|
const outputStylesRoot = join(packageRoot, 'output-styles');
|
|
284
488
|
const targetRoot = resolve(options.targetRoot ?? process.env.PEAKS_CLAUDE_OUTPUT_STYLES_DIR ?? join(homedir(), '.claude', 'output-styles'));
|
|
285
489
|
|
|
@@ -290,6 +494,7 @@ export function installBundledOutputStyles(options = {}) {
|
|
|
290
494
|
const installed = [];
|
|
291
495
|
const skipped = [];
|
|
292
496
|
mkdirSync(targetRoot, { recursive: true });
|
|
497
|
+
const validateOutputStylesRoot = createInstallRootValidator(targetRoot, 'Peaks output styles');
|
|
293
498
|
|
|
294
499
|
for (const outputStyleName of readdirSync(outputStylesRoot)) {
|
|
295
500
|
const sourcePath = join(outputStylesRoot, outputStyleName);
|
|
@@ -302,8 +507,14 @@ export function installBundledOutputStyles(options = {}) {
|
|
|
302
507
|
const current = getPathStats(targetPath);
|
|
303
508
|
if (current) {
|
|
304
509
|
const managedTarget = getManagedTarget(targetPath);
|
|
305
|
-
|
|
510
|
+
const managedTargetIdentity = getManagedPeaksOutputStyleIdentity(managedTarget, targetPath, sourcePath, outputStyleName);
|
|
511
|
+
if (isSameFileIdentity(targetPath, managedTargetIdentity)) {
|
|
512
|
+
validateOutputStylesRoot();
|
|
513
|
+
if (!isSameFileIdentity(targetPath, managedTargetIdentity)) {
|
|
514
|
+
throw new Error('Peaks output style path changed during unlink');
|
|
515
|
+
}
|
|
306
516
|
unlinkSync(targetPath);
|
|
517
|
+
validateOutputStylesRoot();
|
|
307
518
|
unlinkSync(`${targetPath}.peaks-managed`);
|
|
308
519
|
} else {
|
|
309
520
|
skipped.push(outputStyleName);
|
|
@@ -311,8 +522,25 @@ export function installBundledOutputStyles(options = {}) {
|
|
|
311
522
|
}
|
|
312
523
|
}
|
|
313
524
|
|
|
314
|
-
|
|
315
|
-
|
|
525
|
+
const markerPath = `${targetPath}.peaks-managed`;
|
|
526
|
+
validateManagedMarkerPath(markerPath);
|
|
527
|
+
if (!current && existsSync(markerPath)) {
|
|
528
|
+
validateOutputStylesRoot();
|
|
529
|
+
unlinkSync(markerPath);
|
|
530
|
+
}
|
|
531
|
+
const createdTargetIdentity = writeFileExclusively(targetPath, readPackageSourceFile(sourcePath), 'Peaks output style path changed during write', validateOutputStylesRoot);
|
|
532
|
+
try {
|
|
533
|
+
writeFileExclusively(markerPath, createManagedOutputStyleMarker(sourcePath, outputStyleName), 'Peaks managed marker path changed during write', () => {
|
|
534
|
+
validateOutputStylesRoot();
|
|
535
|
+
validateManagedMarkerPath(markerPath);
|
|
536
|
+
});
|
|
537
|
+
} catch (error) {
|
|
538
|
+
validateOutputStylesRoot();
|
|
539
|
+
if (isSameFileIdentity(targetPath, createdTargetIdentity)) {
|
|
540
|
+
unlinkSync(targetPath);
|
|
541
|
+
}
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
316
544
|
installed.push(outputStyleName);
|
|
317
545
|
}
|
|
318
546
|
|