dsh-novel-generator 0.1.3 → 0.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.
Files changed (2) hide show
  1. package/index.js +112 -15
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * UI and watch (or correct) the very same agent the generator drives.
12
12
  */
13
13
  import { randomUUID } from 'node:crypto';
14
- import { mkdir, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises';
14
+ import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
15
15
  import { homedir } from 'node:os';
16
16
  import { basename, join } from 'node:path';
17
17
 
@@ -105,6 +105,79 @@ function projectContentDir(project) {
105
105
  return join(project.cwd, safeSegment(project.title, '未命名作品'));
106
106
  }
107
107
 
108
+ /** Ownership stamp written into the work folder, so delete never eats user data. */
109
+ const MARKER_FILE = '.novel-generator.json';
110
+
111
+ function markerFile(project) {
112
+ return join(projectContentDir(project), MARKER_FILE);
113
+ }
114
+
115
+ async function stampWorkFolder(project) {
116
+ try {
117
+ await mkdir(projectContentDir(project), { recursive: true });
118
+ await writeFile(markerFile(project), JSON.stringify({
119
+ projectId: project.projectId,
120
+ title: project.title,
121
+ createdAt: project.createdAt,
122
+ }, null, 2), 'utf8');
123
+ } catch {
124
+ // A folder that cannot be written simply has no stamp.
125
+ }
126
+ }
127
+
128
+ /** True when this project's folder carries this project's own stamp. */
129
+ async function ownsWorkFolder(project) {
130
+ try {
131
+ const data = JSON.parse(await readFile(markerFile(project), 'utf8'));
132
+ return Boolean(data) && data.projectId === project.projectId;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ async function pathExists(target) {
139
+ try {
140
+ await stat(target);
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Reserve the work folder. An existing folder is reused only when it is empty
149
+ * or already belongs to this plugin for the same project; anything else is a
150
+ * name clash with the user's own files and is reported instead.
151
+ */
152
+ async function reserveWorkFolder(project, projectId) {
153
+ const dir = projectContentDir(project);
154
+ await mkdir(dir, { recursive: true });
155
+ const entries = (await readdir(dir)).filter((name) => name !== MARKER_FILE);
156
+ if (entries.length === 0) return dir;
157
+ let owner = null;
158
+ try {
159
+ owner = JSON.parse(await readFile(join(dir, MARKER_FILE), 'utf8'));
160
+ } catch {
161
+ owner = null;
162
+ }
163
+ if (owner && owner.projectId === projectId) return dir;
164
+ throw httpError(409, `工作目录下已经有名为「${safeSegment(project.title, '未命名作品')}」的文件夹,请换一个作品名称`);
165
+ }
166
+
167
+ /** Remove the work folder only when it is provably this project's own. */
168
+ async function discardWorkFolder(project) {
169
+ if (!project || !project.cwd) return null;
170
+ const dir = projectContentDir(project);
171
+ if (dir === project.cwd) return null;
172
+ if (!(await ownsWorkFolder(project))) return null;
173
+ try {
174
+ await rm(dir, { recursive: true, force: true });
175
+ return dir;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
108
181
  /** Where one chapter lives inside the work's own folder. */
109
182
  function chapterPaths(project, chapter) {
110
183
  const number = String((Number(chapter.index) || 0) + 1).padStart(2, '0');
@@ -408,6 +481,7 @@ function strippedProject(project, unwritten) {
408
481
  /** Write every content file, then the parameter-only index. */
409
482
  async function persistProject(root, project) {
410
483
  const unwritten = new Set();
484
+ await stampWorkFolder(project);
411
485
  for (const chapter of project.chapters || []) {
412
486
  const ok = await exportChapter(project, chapter, chapter.content);
413
487
  if (!ok) unwritten.add(chapter.chapterId);
@@ -973,16 +1047,33 @@ async function bootstrap(ctx, root) {
973
1047
  }
974
1048
  }
975
1049
  const projects = await listProjects(root);
976
- // Repair projects created before sessions were placed in the sidebar group.
1050
+ // Repair projects created before sessions were placed in the sidebar group,
1051
+ // and stamp works made before folders carried an ownership mark.
977
1052
  for (const project of projects) {
978
1053
  try {
979
1054
  await ensureWorkspace(ctx, project);
980
1055
  } catch {
981
1056
  // A missing directory or an older harness simply leaves the session as is.
982
1057
  }
1058
+ try {
1059
+ // Stamp a folder that already exists; a work whose folder was never
1060
+ // written (or was deleted on purpose) is left alone.
1061
+ await stat(projectContentDir(project));
1062
+ if (!(await ownsWorkFolder(project))) await stampWorkFolder(project);
1063
+ } catch {
1064
+ // An unreachable folder simply stays unstamped.
1065
+ }
1066
+ try {
1067
+ if (!(await ownsWorkFolder(project)) && !(await pathExists(projectContentDir(project)))) {
1068
+ // A work created before folders were materialised gets one now.
1069
+ await persistProject(root, await readProject(root, project.projectId));
1070
+ }
1071
+ } catch {
1072
+ // Best effort; the next save materialises it anyway.
1073
+ }
983
1074
  }
984
1075
  return {
985
- revision: '2026-10-20.15',
1076
+ revision: '2026-10-20.17',
986
1077
  projects,
987
1078
  models: {
988
1079
  default: modelCatalog.default || null,
@@ -1021,6 +1112,16 @@ async function createProject(ctx, root, body) {
1021
1112
  }
1022
1113
  : null;
1023
1114
 
1115
+ // Claim the work folder before anything else, so a name clash costs neither a
1116
+ // session nor a half-written project.
1117
+ const projectId = randomUUID();
1118
+ try {
1119
+ await reserveWorkFolder({ projectId, title, cwd }, projectId);
1120
+ } catch (error) {
1121
+ if (error && error.status) throw error;
1122
+ throw httpError(400, `无法在工作目录下创建作品文件夹:${error instanceof Error ? error.message : String(error)}`);
1123
+ }
1124
+
1024
1125
  const created = await controller.create(agentPreset === undefined ? { cwd } : { cwd, agentPreset });
1025
1126
  const sessionId = created && created.sessionId ? created.sessionId : null;
1026
1127
  let warning = null;
@@ -1035,7 +1136,7 @@ async function createProject(ctx, root, body) {
1035
1136
  const today = new Date();
1036
1137
  const origin = today.toISOString().slice(0, 10);
1037
1138
  const project = {
1038
- projectId: randomUUID(),
1139
+ projectId,
1039
1140
  title,
1040
1141
  cwd,
1041
1142
  agentPreset: agentPreset || created.agentPreset || null,
@@ -1048,6 +1149,9 @@ async function createProject(ctx, root, body) {
1048
1149
  chapters: [],
1049
1150
  };
1050
1151
  await writeJson(projectFile(root, project.projectId), project);
1152
+ // Write the stamp and 作品信息.md right away: the folder exists the moment the
1153
+ // work does, instead of appearing on the first chapter.
1154
+ await persistProject(root, project);
1051
1155
 
1052
1156
  // Put the session in a sidebar workspace instead of leaving it ungrouped.
1053
1157
  let workspace = null;
@@ -1556,24 +1660,17 @@ async function dispatch(ctx, root, registry, method, path, url, body) {
1556
1660
  case '/project/delete': {
1557
1661
  const projectId = asText(body.projectId);
1558
1662
  if (projectId === '') throw httpError(400, '缺少 projectId');
1559
- // The work folder is this plugin's own output inside the chosen
1560
- // directory, so removing the work removes it too.
1663
+ // Only a folder carrying this project's own stamp is removed: a folder
1664
+ // the user already had under the same name is never touched.
1561
1665
  let workDir = null;
1562
1666
  try {
1563
1667
  const project = await readProject(root, projectId);
1564
- if (project.cwd) {
1565
- const folder = projectContentDir(project);
1566
- const relative = folder.slice(project.cwd.length).replace(/^[\\/]+/, '');
1567
- if (relative !== '') {
1568
- await rm(folder, { recursive: true, force: true });
1569
- workDir = folder;
1570
- }
1571
- }
1668
+ workDir = await discardWorkFolder(project);
1572
1669
  } catch {
1573
1670
  // A work whose folder is already gone still gets its record removed.
1574
1671
  }
1575
1672
  await rm(join(projectsDir(root), projectId), { recursive: true, force: true });
1576
- return { removed: projectId, removedDir: workDir };
1673
+ return { removed: projectId, removedDir: workDir, folderKept: workDir === null };
1577
1674
  }
1578
1675
  case '/project/timeline':
1579
1676
  return updateTimeline(root, body);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-novel-generator",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Novel studio for DeepSeek Harness: a draggable story timeline, world snapshots, chapter outlines, and chapters written by the work's own agent.",
6
6
  "license": "MIT",