peaks-cli 1.0.8 → 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 CHANGED
File without changes
@@ -1,4 +1,5 @@
1
- import { closeSync, constants, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
1
+ import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
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';
@@ -15,7 +16,13 @@ function isSafeProjectConfigMarker(projectRoot) {
15
16
  const markerPath = resolve(peaksPath, 'config.json');
16
17
  try {
17
18
  const projectRootReal = realpathSync(projectRoot);
19
+ const peaksStats = lstatSync(peaksPath);
18
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;
19
26
  const markerReal = realpathSync(markerPath);
20
27
  if (!isInsidePath(peaksReal, projectRootReal))
21
28
  return false;
@@ -103,6 +110,9 @@ function validateProjectBootstrapConfigPath(projectRootPath, peaksPath, configPa
103
110
  if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
104
111
  throw new Error('Project config path must stay inside the project root');
105
112
  }
113
+ if (markerStats.nlink !== 1) {
114
+ throw new Error('Config path must not be hardlinked');
115
+ }
106
116
  const markerReal = realpathSync(configPath);
107
117
  if (!isInsidePath(markerReal, projectRootReal) || !isInsidePath(markerReal, peaksReal)) {
108
118
  throw new Error('Project config path must stay inside the project root');
@@ -132,6 +142,9 @@ function validateUserConfigPathForWrite(configPath) {
132
142
  if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
133
143
  throw new Error('User config path must stay inside the user root');
134
144
  }
145
+ if (markerStats.nlink !== 1) {
146
+ throw new Error('Config path must not be hardlinked');
147
+ }
135
148
  const markerReal = realpathSync(configPath);
136
149
  if (!isInsidePath(markerReal, userRootReal) || !isInsidePath(markerReal, peaksReal)) {
137
150
  throw new Error('User config path must stay inside the user root');
@@ -184,9 +197,9 @@ function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath
184
197
  }
185
198
  }
186
199
  }
187
- function validateOpenConfigFile(fd, configPath, errorMessage) {
200
+ function validateOpenConfigFile(fd, tempPath, errorMessage) {
188
201
  const fdStats = fstatSync(fd);
189
- const pathStats = lstatSync(configPath);
202
+ const pathStats = lstatSync(tempPath);
190
203
  if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
191
204
  throw new Error(errorMessage);
192
205
  }
@@ -194,21 +207,66 @@ function validateOpenConfigFile(fd, configPath, errorMessage) {
194
207
  throw new Error('Config path must not be hardlinked');
195
208
  }
196
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
+ }
197
227
  function writeConfigFileSafely(configPath, content, validateBeforeWrite, errorMessage) {
198
228
  validateBeforeWrite();
199
- if (typeof constants.O_NOFOLLOW !== 'number') {
200
- throw new Error('Safe config writes require O_NOFOLLOW support');
201
- }
202
- const fd = openSync(configPath, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
229
+ const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
230
+ let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
231
+ let renamed = false;
232
+ let closeError;
203
233
  try {
204
- validateBeforeWrite();
205
- validateOpenConfigFile(fd, configPath, errorMessage);
234
+ validateOpenConfigFile(fd, tempPath, errorMessage);
206
235
  fchmodSync(fd, 0o600);
207
- ftruncateSync(fd, 0);
208
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;
209
250
  }
210
251
  finally {
211
- closeSync(fd);
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
+ }
212
270
  }
213
271
  }
214
272
  function writeProjectConfigFile(projectRoot, configPath, content) {
@@ -217,26 +275,37 @@ function writeProjectConfigFile(projectRoot, configPath, content) {
217
275
  function writeUserConfigFile(configPath, content) {
218
276
  writeConfigFileSafely(configPath, content, () => validateUserConfigPathForWrite(configPath), 'User config path must stay inside the user root');
219
277
  }
220
- function readJsonFile(path) {
278
+ function readJsonFile(path, validateBeforeRead, errorMessage = 'Config path must stay inside the config root') {
221
279
  if (!path || !existsSync(path))
222
280
  return null;
281
+ validateBeforeRead?.();
282
+ const content = readConfigFileSafely(path, errorMessage);
223
283
  try {
224
- return JSON.parse(readFileSync(path, 'utf-8'));
284
+ return JSON.parse(content);
225
285
  }
226
286
  catch {
227
287
  return null;
228
288
  }
229
289
  }
230
- function readExistingJsonFile(path, errorMessage) {
290
+ function readExistingJsonFile(path, errorMessage, validateBeforeRead) {
231
291
  if (!existsSync(path))
232
292
  return null;
293
+ validateBeforeRead?.();
233
294
  try {
234
- return JSON.parse(readFileSync(path, 'utf-8'));
295
+ return JSON.parse(readConfigFileSafely(path, errorMessage));
235
296
  }
236
297
  catch {
237
298
  throw new Error(errorMessage);
238
299
  }
239
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
+ }
240
309
  function ensureDir(dirPath) {
241
310
  if (!existsSync(dirPath)) {
242
311
  mkdirSync(dirPath, { recursive: true });
@@ -516,9 +585,6 @@ function getProjectWriteTarget() {
516
585
  }
517
586
  return { projectRoot, configPath };
518
587
  }
519
- function getProjectWritePath() {
520
- return getProjectWriteTarget().configPath;
521
- }
522
588
  export function containsSensitiveConfigValue(value) {
523
589
  if (Array.isArray(value)) {
524
590
  return value.some(containsSensitiveConfigValue);
@@ -564,14 +630,14 @@ function createMiniMaxProviderStatus(config) {
564
630
  };
565
631
  }
566
632
  export function getMiniMaxProviderConfig() {
567
- return toMiniMaxProviderConfig(readJsonFile(getUserConfigPath())?.providers?.minimax);
633
+ return toMiniMaxProviderConfig(readUserJsonFile()?.providers?.minimax);
568
634
  }
569
635
  export function getMiniMaxProviderStatus() {
570
636
  return createMiniMaxProviderStatus(getMiniMaxProviderConfig());
571
637
  }
572
638
  export function setMiniMaxProviderConfig(input) {
573
639
  validateMiniMaxBaseUrl(input.baseUrl);
574
- const userConfig = readJsonFile(getUserConfigPath()) ?? {};
640
+ const userConfig = readUserJsonFile() ?? {};
575
641
  const existingProviders = toModelProviderConfig(userConfig.providers);
576
642
  const providers = {
577
643
  ...existingProviders,
@@ -617,7 +683,7 @@ function toPeaksConfig(value) {
617
683
  export function bootstrapProjectLanguageConfig(projectRoot, language) {
618
684
  const inferredLanguage = inferHumanLanguage(language);
619
685
  const projectPath = getProjectBootstrapConfigPath(projectRoot);
620
- const existing = readExistingJsonFile(projectPath, 'Project config must contain valid JSON') ?? {};
686
+ const existing = readExistingJsonFile(projectPath, 'Project config must contain valid JSON', () => validateProjectBootstrapConfigPathForWrite(projectRoot, projectPath)) ?? {};
621
687
  if (typeof existing.language === 'string' && existing.language.trim().length > 0) {
622
688
  return;
623
689
  }
@@ -625,10 +691,8 @@ export function bootstrapProjectLanguageConfig(projectRoot, language) {
625
691
  }
626
692
  export function readConfig(projectRoot) {
627
693
  const detectedRoot = projectRoot ?? findProjectRoot(process.cwd());
628
- const userPath = getUserConfigPath();
629
- const projectPath = getProjectConfigPath(detectedRoot);
630
- const userConfig = toPeaksConfig(readJsonFile(userPath));
631
- const projectConfig = removeProjectSensitiveConfig(toPeaksConfig(readJsonFile(projectPath)));
694
+ const userConfig = toPeaksConfig(readUserJsonFile());
695
+ const projectConfig = removeProjectSensitiveConfig(toPeaksConfig(readProjectJsonFile(detectedRoot)));
632
696
  const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
633
697
  const userWorkspaces = userConfig.workspaces ?? [];
634
698
  return {
@@ -650,21 +714,21 @@ export function writeConfig(partial, layer = 'user') {
650
714
  if (layer === 'project') {
651
715
  const { projectRoot, configPath } = getProjectWriteTarget();
652
716
  ensureDir(dirname(configPath));
653
- const existing = readJsonFile(configPath) ?? {};
717
+ const existing = readJsonFile(configPath, () => validateProjectBootstrapConfigPathForWrite(projectRoot, configPath)) ?? {};
654
718
  const merged = { ...existing, ...partial };
655
719
  writeProjectConfigFile(projectRoot, configPath, JSON.stringify(merged, null, 2));
656
720
  return;
657
721
  }
658
722
  const userPath = getUserConfigPath();
659
723
  ensureDir(dirname(userPath));
660
- const existing = readJsonFile(userPath) ?? {};
724
+ const existing = readJsonFile(userPath, () => validateUserConfigPathForWrite(userPath)) ?? {};
661
725
  const merged = { ...existing, ...partial };
662
726
  writeUserConfigFile(userPath, JSON.stringify(merged, null, 2));
663
727
  }
664
728
  export function getConfig(options = {}) {
665
729
  const projectRoot = findProjectRoot(process.cwd());
666
- const userConfig = readJsonFile(getUserConfigPath()) ?? {};
667
- const projectConfig = removeProjectSensitiveConfig(readJsonFile(getProjectConfigPath(projectRoot)) ?? {});
730
+ const userConfig = readUserJsonFile() ?? {};
731
+ const projectConfig = removeProjectSensitiveConfig(readProjectJsonFile(projectRoot) ?? {});
668
732
  const { proxy: projectProxy, workspaces: projectWorkspaces, ...projectConfigWithoutProxy } = projectConfig;
669
733
  const source = options.layer === 'user'
670
734
  ? userConfig
@@ -706,7 +770,9 @@ export function setConfig(options) {
706
770
  const projectTarget = layer === 'project' ? getProjectWriteTarget() : null;
707
771
  const targetPath = projectTarget?.configPath ?? getUserConfigPath();
708
772
  ensureDir(dirname(targetPath));
709
- const existing = readJsonFile(targetPath) ?? {};
773
+ const existing = projectTarget
774
+ ? readJsonFile(targetPath, () => validateProjectBootstrapConfigPathForWrite(projectTarget.projectRoot, targetPath)) ?? {}
775
+ : readJsonFile(targetPath, () => validateUserConfigPathForWrite(targetPath)) ?? {};
710
776
  const updated = { ...existing };
711
777
  setNestedValue(updated, options.key, options.value);
712
778
  const content = JSON.stringify(updated, null, 2);
@@ -1,5 +1,6 @@
1
+ import { CLI_VERSION } from '../../shared/version.js';
1
2
  export const DEFAULT_CONFIG = {
2
- version: '0.1.0',
3
+ version: CLI_VERSION,
3
4
  currentWorkspace: null,
4
5
  workspaces: [],
5
6
  language: 'en',
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.0.8";
1
+ export declare const CLI_VERSION = "1.0.9";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.0.8";
1
+ export const CLI_VERSION = "1.0.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { closeSync, constants, copyFileSync, existsSync, fchmodSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
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,22 +17,140 @@ 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 readFileSync(markerPath, 'utf8').trim();
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
- writeFileSync(markerPath, `${sourcePath}\n`, 'utf8');
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 isManagedPeaksOutputStyle(managedTarget, outputStyleName) {
33
- if (managedTarget === null) return false;
34
- return managedTarget.replaceAll('\\', '/').endsWith(`/output-styles/${outputStyleName}`);
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() {
@@ -104,7 +223,7 @@ function readConfigFile(configPath, label) {
104
223
  }
105
224
 
106
225
  try {
107
- const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
226
+ const parsed = JSON.parse(readFileSafely(configPath, `${label} config path changed during read`));
108
227
  if (!isPlainObject(parsed)) {
109
228
  throw new Error(`${label} config must contain a JSON object`);
110
229
  }
@@ -132,6 +251,9 @@ function validateConfigPath(root, peaksRoot, configPath, label) {
132
251
  throw new Error(`${label} config path must be a file`);
133
252
  }
134
253
  if (configStats) {
254
+ if (configStats.nlink !== 1) {
255
+ throw new Error(`${label} config path must not be hardlinked`);
256
+ }
135
257
  const configReal = realpathSync(configPath);
136
258
  if (!isInsidePath(configReal, rootReal) || !isInsidePath(configReal, peaksReal)) {
137
259
  throw new Error(`${label} config path must stay inside the ${label.toLowerCase()} root`);
@@ -147,41 +269,93 @@ function validateUserConfigPaths(userRoot, peaksRoot, configPath) {
147
269
  validateConfigPath(userRoot, peaksRoot, configPath, 'User');
148
270
  }
149
271
 
150
- function validateOpenConfigFile(fd, configPath, label) {
151
- const fdStats = fstatSync(fd);
152
- const pathStats = lstatSync(configPath);
153
- if (!fdStats.isFile() || !pathStats.isFile() || fdStats.dev !== pathStats.dev || fdStats.ino !== pathStats.ino) {
154
- throw new Error(`${label} config path changed during write`);
155
- }
156
- if (fdStats.nlink !== 1 || pathStats.nlink !== 1) {
157
- throw new Error(`${label} config path must not be hardlinked`);
158
- }
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;
159
275
  }
160
276
 
161
- function writeConfigFile(configPath, content, label, validateBeforeWrite) {
277
+ function writeFileExclusively(path, content, errorMessage, validateBeforeWrite) {
162
278
  validateBeforeWrite();
163
- if (typeof constants.O_NOFOLLOW !== 'number') {
164
- throw new Error('Safe config writes require O_NOFOLLOW support');
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
+ }
165
307
  }
308
+ }
166
309
 
167
- const fd = openSync(configPath, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
310
+ function writeFileAtomically(configPath, content, errorMessage, validateBeforeWrite) {
311
+ validateBeforeWrite();
312
+
313
+ const tempPath = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
314
+ let fd = openSync(tempPath, getSafeTempOpenFlags(), 0o600);
315
+ let renamed = false;
316
+ let closeError = null;
168
317
  try {
169
- validateBeforeWrite();
170
- validateOpenConfigFile(fd, configPath, label);
318
+ validateOpenFile(fd, tempPath, errorMessage);
171
319
  fchmodSync(fd, 0o600);
172
- ftruncateSync(fd, 0);
173
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;
174
333
  } finally {
175
- closeSync(fd);
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
+ }
176
350
  }
177
351
  }
178
352
 
179
353
  function writeProjectConfig(projectRoot, peaksRoot, configPath, content) {
180
- writeConfigFile(configPath, content, 'Project', () => validateProjectConfigPaths(projectRoot, peaksRoot, configPath));
354
+ writeFileAtomically(configPath, content, 'Project config path changed during write', () => validateProjectConfigPaths(projectRoot, peaksRoot, configPath));
181
355
  }
182
356
 
183
357
  function writeUserConfig(userRoot, peaksRoot, configPath, content) {
184
- writeConfigFile(configPath, content, 'User', () => validateUserConfigPaths(userRoot, peaksRoot, configPath));
358
+ writeFileAtomically(configPath, content, 'User config path changed during write', () => validateUserConfigPaths(userRoot, peaksRoot, configPath));
185
359
  }
186
360
 
187
361
  function resolveProjectRoot(options) {
@@ -259,6 +433,7 @@ export function installBundledSkills(options = {}) {
259
433
  const installed = [];
260
434
  const skipped = [];
261
435
  mkdirSync(targetRoot, { recursive: true });
436
+ const validateSkillsRoot = createInstallRootValidator(targetRoot, 'Peaks skills');
262
437
 
263
438
  for (const skillName of readdirSync(skillsRoot)) {
264
439
  const sourcePath = join(skillsRoot, skillName);
@@ -272,12 +447,15 @@ export function installBundledSkills(options = {}) {
272
447
  const current = getPathStats(targetPath);
273
448
  if (current) {
274
449
  const managedTarget = getManagedTarget(targetPath);
275
- if (current.isSymbolicLink() && readlinkSync(targetPath) === sourcePath) {
450
+ const linkTarget = current.isSymbolicLink() ? readlinkSync(targetPath) : null;
451
+ if (linkTarget === sourcePath) {
276
452
  installed.push(skillName);
277
453
  continue;
278
454
  }
279
- if (isBrokenSymlink(current, targetPath) && managedTarget === readlinkSync(targetPath)) {
455
+ if ((current.isSymbolicLink() || isBrokenSymlink(current, targetPath)) && managedTarget === linkTarget) {
456
+ validateSkillsRoot();
280
457
  unlinkSync(targetPath);
458
+ validateSkillsRoot();
281
459
  unlinkSync(`${targetPath}.peaks-managed`);
282
460
  } else {
283
461
  skipped.push(skillName);
@@ -285,8 +463,19 @@ export function installBundledSkills(options = {}) {
285
463
  }
286
464
  }
287
465
 
466
+ validateSkillsRoot();
288
467
  symlinkSync(sourcePath, targetPath, process.platform === 'win32' ? 'junction' : 'dir');
289
- markManagedPeaksLink(targetPath, sourcePath);
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
+ }
290
479
  installed.push(skillName);
291
480
  }
292
481
 
@@ -305,6 +494,7 @@ export function installBundledOutputStyles(options = {}) {
305
494
  const installed = [];
306
495
  const skipped = [];
307
496
  mkdirSync(targetRoot, { recursive: true });
497
+ const validateOutputStylesRoot = createInstallRootValidator(targetRoot, 'Peaks output styles');
308
498
 
309
499
  for (const outputStyleName of readdirSync(outputStylesRoot)) {
310
500
  const sourcePath = join(outputStylesRoot, outputStyleName);
@@ -317,8 +507,14 @@ export function installBundledOutputStyles(options = {}) {
317
507
  const current = getPathStats(targetPath);
318
508
  if (current) {
319
509
  const managedTarget = getManagedTarget(targetPath);
320
- if (isManagedPeaksOutputStyle(managedTarget, outputStyleName)) {
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
+ }
321
516
  unlinkSync(targetPath);
517
+ validateOutputStylesRoot();
322
518
  unlinkSync(`${targetPath}.peaks-managed`);
323
519
  } else {
324
520
  skipped.push(outputStyleName);
@@ -326,8 +522,25 @@ export function installBundledOutputStyles(options = {}) {
326
522
  }
327
523
  }
328
524
 
329
- copyFileSync(sourcePath, targetPath);
330
- markManagedPeaksLink(targetPath, sourcePath);
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
+ }
331
544
  installed.push(outputStyleName);
332
545
  }
333
546