@sovovs/bycli 2.1.2 → 2.1.4

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.
@@ -288,6 +288,15 @@ async function handleRequest(req, res) {
288
288
  }
289
289
  const result = saveAdapterSource(body);
290
290
  if (!result.ok) {
291
+ if (result.errorCode === 'adapter_exists') {
292
+ jsonResponse(res, 409, {
293
+ ok: false,
294
+ errorCode: result.errorCode,
295
+ error: result.reason,
296
+ data: { adapterPath: result.adapterPath },
297
+ });
298
+ return;
299
+ }
291
300
  jsonResponse(res, 400, { ok: false, errorCode: result.errorCode, error: result.reason });
292
301
  return;
293
302
  }
@@ -411,7 +420,7 @@ async function handleRequest(req, res) {
411
420
  setTimeout(() => shutdown(), 100);
412
421
  return;
413
422
  }
414
- if (req.method === 'POST' && url === '/command') {
423
+ if (req.method === 'POST' && pathname === '/command') {
415
424
  try {
416
425
  const body = JSON.parse(await readBody(req));
417
426
  if (!body.id) {
@@ -93,6 +93,8 @@ export interface SaveAdapterInput {
93
93
  source: string;
94
94
  /** 生成所用模型(标进 provenance)。 */
95
95
  llmModel?: string;
96
+ /** 是否允许覆盖已有 adapter;默认 false。 */
97
+ overwrite?: boolean;
96
98
  }
97
99
  export type SaveAdapterResult = {
98
100
  ok: true;
@@ -102,6 +104,11 @@ export type SaveAdapterResult = {
102
104
  ok: false;
103
105
  errorCode: 'validation_failed';
104
106
  reason: string;
107
+ } | {
108
+ ok: false;
109
+ errorCode: 'adapter_exists';
110
+ reason: string;
111
+ adapterPath: string;
105
112
  };
106
113
  export declare function saveAdapterSource(input: SaveAdapterInput): SaveAdapterResult;
107
114
  export interface InitRecoveryResult {
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
  import { getUserClisDir, getSitesDir, getSiteRecorderDir } from '../../config-paths.js';
19
+ import { getCliManifestPath } from '../../package-paths.js';
19
20
  import { createHash, randomUUID } from 'node:crypto';
20
21
  import { validateAdapterName, renderAdapterTemplate, buildProvenanceHeader, computeDryRunDiff, decideInitRecovery, } from '@sovovs/bycli-recorder-core';
21
22
  const DEFAULT_SNAPSHOT = {
@@ -47,16 +48,98 @@ function atomicWrite(finalPath, content) {
47
48
  const dir = path.dirname(finalPath);
48
49
  fs.mkdirSync(dir, { recursive: true });
49
50
  const tmp = path.join(dir, `.${path.basename(finalPath)}.${randomUUID()}.tmp`);
50
- const fd = fs.openSync(tmp, 'wx', 0o600); // exclusive create
51
+ let fd;
51
52
  try {
52
- fs.writeSync(fd, content);
53
+ fd = fs.openSync(tmp, 'wx', 0o600); // exclusive create
54
+ fs.writeFileSync(fd, content);
53
55
  fs.fsyncSync(fd);
56
+ fs.closeSync(fd);
57
+ fd = undefined;
58
+ fs.renameSync(tmp, finalPath);
59
+ fsyncDir(dir);
54
60
  }
55
61
  finally {
56
- fs.closeSync(fd);
62
+ if (fd !== undefined)
63
+ fs.closeSync(fd);
64
+ // A failed write/rename must not strand sibling temp files.
65
+ try {
66
+ fs.rmSync(tmp, { force: true });
67
+ }
68
+ catch { /* best-effort cleanup */ }
69
+ }
70
+ }
71
+ /**
72
+ * Commit a fully-written sibling temp as the live adapter.
73
+ *
74
+ * For overwrite=false, hard-link creation is the atomic no-clobber primitive: it either
75
+ * creates finalPath pointing at the complete inode, or fails with EEXIST. Unlike an
76
+ * existsSync()+rename sequence, concurrent saves cannot both win. The temp is guaranteed to
77
+ * be on the same filesystem because it is created beside finalPath.
78
+ */
79
+ function commitAdapterTemp(tmp, finalPath, overwrite) {
80
+ const dir = path.dirname(finalPath);
81
+ if (overwrite) {
82
+ fs.renameSync(tmp, finalPath);
83
+ }
84
+ else {
85
+ try {
86
+ fs.linkSync(tmp, finalPath);
87
+ }
88
+ catch (err) {
89
+ if (err.code === 'EEXIST')
90
+ return false;
91
+ throw err;
92
+ }
93
+ fs.unlinkSync(tmp);
57
94
  }
58
- fs.renameSync(tmp, finalPath);
59
95
  fsyncDir(dir);
96
+ return true;
97
+ }
98
+ function isPathInside(parent, candidate) {
99
+ const relative = path.relative(parent, candidate);
100
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
101
+ }
102
+ /** Resolve/create the adapter directory while refusing a site symlink or non-directory. */
103
+ function resolveSafeAdapterPath(site, command) {
104
+ const root = path.resolve(getUserClisDir());
105
+ fs.mkdirSync(root, { recursive: true });
106
+ const canonicalRoot = fs.realpathSync(root);
107
+ const siteDir = path.join(root, site);
108
+ try {
109
+ fs.mkdirSync(siteDir, { mode: 0o700 });
110
+ }
111
+ catch (err) {
112
+ if (err.code !== 'EEXIST')
113
+ throw err;
114
+ }
115
+ const siteStat = fs.lstatSync(siteDir);
116
+ if (siteStat.isSymbolicLink() || !siteStat.isDirectory()) {
117
+ return { ok: false, reason: 'adapter site path must be a real directory under the byCLI config root' };
118
+ }
119
+ const canonicalSite = fs.realpathSync(siteDir);
120
+ if (!isPathInside(canonicalRoot, canonicalSite) || path.dirname(canonicalSite) !== canonicalRoot) {
121
+ return { ok: false, reason: 'adapter site path escapes the byCLI config root' };
122
+ }
123
+ const adapterPath = path.join(siteDir, `${command}.js`);
124
+ try {
125
+ const targetStat = fs.lstatSync(adapterPath);
126
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
127
+ return { ok: false, reason: 'adapter target must be a regular file' };
128
+ }
129
+ }
130
+ catch (err) {
131
+ if (err.code !== 'ENOENT')
132
+ throw err;
133
+ }
134
+ return { ok: true, adapterPath };
135
+ }
136
+ /**
137
+ * User adapters are filesystem-authoritative. Removing the optional compiled user manifest
138
+ * makes the next byCLI process fall back to scanning clis/, so a new or replaced adapter is
139
+ * discovered without a second daemon notification endpoint.
140
+ */
141
+ function invalidateUserCliManifest() {
142
+ fs.rmSync(getCliManifestPath(getUserClisDir()), { force: true });
60
143
  }
61
144
  function writeManifest(manifestPath, manifest) {
62
145
  atomicWrite(manifestPath, JSON.stringify(manifest));
@@ -131,16 +214,61 @@ export function saveAdapterSource(input) {
131
214
  if (typeof input.source !== 'string' || !input.source.trim()) {
132
215
  return { ok: false, errorCode: 'validation_failed', reason: 'source required' };
133
216
  }
217
+ if (input.overwrite !== undefined && typeof input.overwrite !== 'boolean') {
218
+ return { ok: false, errorCode: 'validation_failed', reason: 'overwrite must be a boolean' };
219
+ }
134
220
  const { site, command } = v.parts;
135
- const adapterPath = path.join(clisDir(site), `${command}.js`);
221
+ const resolved = resolveSafeAdapterPath(site, command);
222
+ if (!resolved.ok)
223
+ return { ok: false, errorCode: 'validation_failed', reason: resolved.reason };
224
+ const { adapterPath } = resolved;
136
225
  const reportPath = path.join(getSiteRecorderDir(site), `${command}-report.json`);
137
226
  const txnId = randomUUID();
138
227
  const report = { adapterPath, reportPath, source: 'llm-generated', llmModel: input.llmModel ?? null, savedAt: Date.now() };
139
228
  const reportJson = JSON.stringify(report, null, 2);
140
229
  const header = buildProvenanceHeader({ txnId, reportPath, reportSha256: sha256(reportJson), llmModel: input.llmModel });
141
230
  const rendered = input.source.startsWith('// @generated-by') ? input.source : `${header}\n${input.source}`;
231
+ const overwrite = input.overwrite ?? false;
232
+ const dir = path.dirname(adapterPath);
233
+ const tmp = path.join(dir, `.${path.basename(adapterPath)}.${randomUUID()}.tmp`);
234
+ let fd;
235
+ try {
236
+ fd = fs.openSync(tmp, 'wx', 0o600);
237
+ fs.writeFileSync(fd, rendered);
238
+ fs.fsyncSync(fd);
239
+ fs.closeSync(fd);
240
+ fd = undefined;
241
+ // Re-check the directory after creating the temp, immediately before publication. This
242
+ // catches ordinary symlink replacement attempts; name validation plus sibling temp/link
243
+ // keeps the final path contained and the no-clobber decision atomic.
244
+ const canonicalRoot = fs.realpathSync(path.resolve(getUserClisDir()));
245
+ const canonicalDir = fs.realpathSync(dir);
246
+ if (fs.lstatSync(dir).isSymbolicLink() || path.dirname(canonicalDir) !== canonicalRoot) {
247
+ return { ok: false, errorCode: 'validation_failed', reason: 'adapter site path changed during save' };
248
+ }
249
+ // Invalidation is part of the same save operation. Do it before publishing so a successful
250
+ // adapter commit can never remain hidden behind a stale user manifest.
251
+ invalidateUserCliManifest();
252
+ if (!commitAdapterTemp(tmp, adapterPath, overwrite)) {
253
+ return {
254
+ ok: false,
255
+ errorCode: 'adapter_exists',
256
+ reason: 'CLI adapter already exists',
257
+ adapterPath,
258
+ };
259
+ }
260
+ }
261
+ finally {
262
+ if (fd !== undefined)
263
+ fs.closeSync(fd);
264
+ try {
265
+ fs.rmSync(tmp, { force: true });
266
+ }
267
+ catch { /* best-effort cleanup */ }
268
+ }
269
+ // The adapter file is authoritative; recorder metadata is refreshed only after the adapter
270
+ // has been atomically published, so an adapter_exists response never changes the report.
142
271
  atomicWrite(reportPath, reportJson);
143
- atomicWrite(adapterPath, rendered);
144
272
  return { ok: true, adapterPath, reportPath };
145
273
  }
146
274
  /** First line of every recorder-generated adapter (buildProvenanceHeader). Recovery only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },