@cloud411716/fancy-webnovel 2.2.0 → 3.0.3

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 (212) hide show
  1. package/README.md +65 -0
  2. package/client.js +201 -15
  3. package/cordis.patch.yml +5 -1
  4. package/docs/douyin-plugin.md +118 -0
  5. package/docs/material-library/README.md +404 -0
  6. package/docs/material-library/import.js +437 -0
  7. package/docs/material-library/prompt.md +370 -0
  8. package/docs/material-library/queries.surql +94 -0
  9. package/docs/material-library/sample.jsonl +14 -0
  10. package/docs/material-library/schema.surql +46 -0
  11. package/docs/mcp-profile.example.yml +24 -0
  12. package/docs/story-graph-design.md +602 -0
  13. package/docs/story-graph-validation.md +81 -0
  14. package/docs/workspace-mcp.md +284 -0
  15. package/index.js +12 -7
  16. package/lib/atomic-write.js +109 -0
  17. package/lib/chapter-order.js +100 -0
  18. package/lib/confirmation.js +80 -0
  19. package/lib/db-init.js +123 -0
  20. package/lib/deployed-state.js +11 -11
  21. package/lib/direction-labels.js +265 -0
  22. package/lib/error-codes.js +31 -31
  23. package/lib/infra.js +29 -4
  24. package/lib/material-db.js +149 -0
  25. package/lib/path-guard.js +150 -0
  26. package/lib/projects.js +400 -0
  27. package/lib/scraper-utils.js +140 -0
  28. package/lib/surreal-server.js +338 -0
  29. package/lib/topic-decision.js +860 -0
  30. package/lib/topic-fields.js +251 -0
  31. package/lib/topic-schema.js +225 -0
  32. package/lib/workspace.js +501 -0
  33. package/package.json +31 -7
  34. package/plugins/fancy-analyze/analyze-definitions.md +519 -0
  35. package/plugins/fancy-analyze/index.js +1128 -77
  36. package/plugins/fancy-analyze/ingest-coverage.js +107 -0
  37. package/plugins/fancy-analyze/schema-analyze.md +355 -107
  38. package/plugins/fancy-analyze/scripts/append-summary-chapter.js +612 -0
  39. package/plugins/fancy-analyze/summary-progress.js +71 -7
  40. package/plugins/fancy-analyze/templates.js +437 -139
  41. package/plugins/fancy-bootstrap/index.js +276 -131
  42. package/plugins/fancy-bootstrap/scripts/init.js +59 -25
  43. package/plugins/fancy-bootstrap/templates.js +35 -18
  44. package/plugins/fancy-db/index.js +172 -0
  45. package/plugins/fancy-douyin/deploy.js +134 -0
  46. package/plugins/fancy-douyin/index.js +551 -0
  47. package/plugins/fancy-douyin/prompts/01-analysis-prompt.md +84 -0
  48. package/plugins/fancy-douyin/scripts/crawl-note.cjs +194 -0
  49. package/plugins/fancy-douyin/scripts/lib.cjs +197 -0
  50. package/plugins/fancy-douyin/scripts/scan-account.cjs +128 -0
  51. package/plugins/fancy-douyin/scripts/start-chrome.cjs +203 -0
  52. package/plugins/fancy-douyin/state.js +197 -0
  53. package/plugins/fancy-douyin/templates.js +127 -0
  54. package/plugins/fancy-export/exporter.js +805 -0
  55. package/plugins/fancy-export/index.js +63 -0
  56. package/plugins/fancy-import/import-driver.js +29 -6
  57. package/plugins/fancy-import/index.js +13 -5
  58. package/plugins/fancy-import/prompts/01-import-prompt.md +39 -14
  59. package/plugins/fancy-import/templates.js +17 -8
  60. package/plugins/fancy-material/exporter.js +153 -0
  61. package/plugins/fancy-material/genres.js +256 -0
  62. package/plugins/fancy-material/index.js +835 -0
  63. package/plugins/fancy-material/inventory.js +145 -0
  64. package/plugins/fancy-material/kinds.js +313 -0
  65. package/plugins/fancy-material/queue.js +55 -0
  66. package/plugins/fancy-material/scripts/query.js +254 -0
  67. package/plugins/fancy-material/templates.js +258 -0
  68. package/plugins/fancy-material/tool-deploy.js +138 -0
  69. package/plugins/fancy-material/validate.js +149 -0
  70. package/plugins/fancy-project/index.js +133 -0
  71. package/plugins/fancy-scan/existing-block.js +100 -0
  72. package/plugins/fancy-scan/index.js +410 -21
  73. package/plugins/fancy-scan/prompts/01-analysis-prompt.md +302 -0
  74. package/plugins/fancy-scan/scripts/append-block.js +725 -0
  75. package/plugins/fancy-scan/scripts/report-validate.js +824 -0
  76. package/plugins/fancy-scan/scripts/run-scan.js +169 -22
  77. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +145 -16
  78. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +179 -49
  79. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +38 -20
  80. package/plugins/fancy-scan/templates/blocks.js +155 -0
  81. package/plugins/fancy-scan/templates/platforms.js +372 -0
  82. package/plugins/fancy-scan/templates/report-schema.js +90 -0
  83. package/plugins/fancy-scan/templates.js +545 -146
  84. package/plugins/fancy-scan/tool-deploy.js +335 -0
  85. package/plugins/fancy-scan/wait-turn.js +46 -0
  86. package/plugins/fancy-story-builder/db/README.md +106 -0
  87. package/plugins/fancy-story-builder/db/client.js +228 -0
  88. package/plugins/fancy-story-builder/db/commands.js +145 -0
  89. package/plugins/fancy-story-builder/db/ids.js +39 -0
  90. package/plugins/fancy-story-builder/db/import.js +798 -0
  91. package/plugins/fancy-story-builder/db/project.js +299 -0
  92. package/plugins/fancy-story-builder/db/query.js +361 -0
  93. package/plugins/fancy-story-builder/db/schema.js +417 -0
  94. package/plugins/fancy-story-builder/db/stage-sync.js +238 -0
  95. package/plugins/fancy-story-builder/flesh-output.js +19 -6
  96. package/plugins/fancy-story-builder/index.js +4105 -396
  97. package/plugins/fancy-story-builder/patch-driver.js +390 -0
  98. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +29 -19
  99. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +72 -10
  100. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +40 -5
  101. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +37 -0
  102. package/plugins/fancy-story-builder/purpose-template.md +42 -66
  103. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -56
  104. package/plugins/fancy-story-builder/references/genre-profiles.json +3514 -0
  105. package/plugins/fancy-story-builder/references/glossary.md +15 -6
  106. package/plugins/fancy-story-builder/references/index.md +97 -6
  107. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +1 -1
  108. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +25 -7
  109. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +5 -1
  110. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +6 -2
  111. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +15 -5
  112. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +1 -1
  113. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +2 -1
  114. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +1 -1
  115. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +19 -1
  116. package/plugins/fancy-story-builder/schema-setting.json +1100 -0
  117. package/plugins/fancy-story-builder/schema-setting.md +201 -293
  118. package/plugins/fancy-story-builder/scripts/brief-validate.js +164 -0
  119. package/plugins/fancy-story-builder/scripts/content-quality.js +4 -1
  120. package/plugins/fancy-story-builder/scripts/flesh-validate.js +135 -12
  121. package/plugins/fancy-story-builder/scripts/graph-utils.js +146 -47
  122. package/plugins/fancy-story-builder/scripts/reference-log.js +110 -0
  123. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +100 -4
  124. package/plugins/fancy-story-builder/scripts/schema-pages.js +2668 -0
  125. package/plugins/fancy-story-builder/scripts/semantic-lint.js +5 -1
  126. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +158 -17
  127. package/plugins/fancy-story-builder/scripts/stage-batches.js +36 -2
  128. package/plugins/fancy-story-builder/scripts/stage-contract.js +12 -0
  129. package/plugins/fancy-story-builder/scripts/story-artifacts.js +933 -0
  130. package/plugins/fancy-story-builder/scripts/upstream-query.js +1 -1
  131. package/plugins/fancy-story-builder/scripts/v2/baseline.js +112 -0
  132. package/plugins/fancy-story-builder/scripts/v2/cast.js +517 -0
  133. package/plugins/fancy-story-builder/scripts/v2/context.js +434 -0
  134. package/plugins/fancy-story-builder/scripts/v2/contracts.js +493 -0
  135. package/plugins/fancy-story-builder/scripts/v2/core.js +419 -0
  136. package/plugins/fancy-story-builder/scripts/v2/engine.js +1068 -0
  137. package/plugins/fancy-story-builder/scripts/v2/export-markdown.js +794 -0
  138. package/plugins/fancy-story-builder/scripts/v2/genre.js +195 -0
  139. package/plugins/fancy-story-builder/scripts/v2/knowledge.js +321 -0
  140. package/plugins/fancy-story-builder/scripts/v2/macro.js +409 -0
  141. package/plugins/fancy-story-builder/scripts/v2/manifest.js +538 -0
  142. package/plugins/fancy-story-builder/scripts/v2/migrate.js +137 -0
  143. package/plugins/fancy-story-builder/scripts/v2/prompts.js +1335 -0
  144. package/plugins/fancy-story-builder/scripts/v2/rhythm.js +618 -0
  145. package/plugins/fancy-story-builder/scripts/v2/scale.js +843 -0
  146. package/plugins/fancy-story-builder/scripts/v2/snapshot.js +121 -0
  147. package/plugins/fancy-story-builder/scripts/v2/stages.js +273 -0
  148. package/plugins/fancy-story-builder/scripts/v2/writing.js +216 -0
  149. package/plugins/fancy-story-builder/storydb/README.md +271 -0
  150. package/plugins/fancy-story-builder/storydb/cli.js +458 -0
  151. package/plugins/fancy-story-builder/storydb/client.js +351 -0
  152. package/plugins/fancy-story-builder/storydb/compile.js +452 -0
  153. package/plugins/fancy-story-builder/storydb/context.js +288 -0
  154. package/plugins/fancy-story-builder/storydb/definitions.js +699 -0
  155. package/plugins/fancy-story-builder/storydb/driver.js +106 -0
  156. package/plugins/fancy-story-builder/storydb/dsh-tools.js +313 -0
  157. package/plugins/fancy-story-builder/storydb/export.js +175 -0
  158. package/plugins/fancy-story-builder/storydb/ids.js +31 -0
  159. package/plugins/fancy-story-builder/storydb/impact.js +170 -0
  160. package/plugins/fancy-story-builder/storydb/import.js +559 -0
  161. package/plugins/fancy-story-builder/storydb/mcp.js +99 -0
  162. package/plugins/fancy-story-builder/storydb/prompts.js +952 -0
  163. package/plugins/fancy-story-builder/storydb/stage-command.js +379 -0
  164. package/plugins/fancy-story-builder/storydb/stage.js +426 -0
  165. package/plugins/fancy-story-builder/storydb/store.js +891 -0
  166. package/plugins/fancy-story-builder/storydb/tools.js +592 -0
  167. package/plugins/fancy-story-builder/storydb/validation.js +183 -0
  168. package/plugins/fancy-story-builder/templates/v2/10-scale/scale-template.json +144 -0
  169. package/plugins/fancy-story-builder/templates/v2/20-macro/emotion-template.json +15 -0
  170. package/plugins/fancy-story-builder/templates/v2/20-macro/endings-template.json +16 -0
  171. package/plugins/fancy-story-builder/templates/v2/20-macro/lines-template.json +28 -0
  172. package/plugins/fancy-story-builder/templates/v2/20-macro/roles-template.json +10 -0
  173. package/plugins/fancy-story-builder/templates/v2/20-macro/story-template.json +19 -0
  174. package/plugins/fancy-story-builder/templates/v2/20-macro/volumes-template.json +24 -0
  175. package/plugins/fancy-story-builder/templates/v2/20-macro/world-template.json +24 -0
  176. package/plugins/fancy-story-builder/templates/v2/30-cast/characters-template.json +49 -0
  177. package/plugins/fancy-story-builder/templates/v2/30-cast/climaxes-template.json +14 -0
  178. package/plugins/fancy-story-builder/templates/v2/30-cast/conflicts-template.json +30 -0
  179. package/plugins/fancy-story-builder/templates/v2/30-cast/factions-template.json +18 -0
  180. package/plugins/fancy-story-builder/templates/v2/30-cast/foreshadows-template.json +29 -0
  181. package/plugins/fancy-story-builder/templates/v2/30-cast/information-template.json +29 -0
  182. package/plugins/fancy-story-builder/templates/v2/30-cast/items-template.json +13 -0
  183. package/plugins/fancy-story-builder/templates/v2/30-cast/locations-template.json +12 -0
  184. package/plugins/fancy-story-builder/templates/v2/30-cast/payoffs-template.json +16 -0
  185. package/plugins/fancy-story-builder/templates/v2/30-cast/relations-template.json +44 -0
  186. package/plugins/fancy-story-builder/templates/v2/40-rhythm/chapter-map-template.json +81 -0
  187. package/plugins/fancy-story-builder/templates/v2/40-rhythm/outline-template.json +162 -0
  188. package/plugins/fancy-story-builder/templates/v2/50-writing/ledger-template.json +46 -0
  189. package/plugins/fancy-story-builder/templates/v2/60-deslop/report-template.json +10 -0
  190. package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +267 -44
  191. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +121 -19
  192. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +129 -21
  193. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/266/344/273/226/345/256/236/344/275/223/346/250/241/346/235/277.json +62 -0
  194. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +76 -17
  195. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +56 -8
  196. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +63 -13
  197. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +34 -6
  198. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +81 -18
  199. package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +202 -42
  200. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +160 -27
  201. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +217 -41
  202. package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +215 -41
  203. package/plugins/fancy-story-builder/templates.js +272 -74
  204. package/plugins/fancy-story-builder/wiki-references.js +429 -93
  205. package/plugins/fancy-story-builder/writing-driver.js +336 -9
  206. package/plugins/fancy-topic/index.js +414 -61
  207. package/plugins/fancy-topic/prompts/01-exploration-prompt.md +115 -0
  208. package/plugins/fancy-topic/scripts/save-suggestion.js +219 -0
  209. package/plugins/fancy-topic/scripts/select-topic.js +106 -63
  210. package/plugins/fancy-topic/templates.js +225 -122
  211. package/plugins/fancy-topic/tool-deploy.js +200 -0
  212. package/lib/safe-rm.js +0 -94
@@ -1,168 +1,313 @@
1
1
  /**
2
- * fancy-bootstrap plugin entry
2
+ * fancy-bootstrap — first-time workspace initialization.
3
+ *
4
+ * A workspace is a directory the user chooses once. It owns the shared
5
+ * scan/analyze artifacts, the SurrealDB server data, and every project
6
+ * directory under <workspace>/projects/<uuid>.
3
7
  */
4
- import { fileURLToPath } from 'url';
5
- import { dirname, join } from 'path';
6
- import { askUserQuestion, notify, spawnScript } from '#infra';
7
- import { readDeployedState, deployedStateProblemText } from '#deployed-state';
8
- import { bootstrap } from './templates.js';
8
+
9
+ import { existsSync, realpathSync, statSync } from 'fs';
10
+ import { homedir } from 'os';
11
+ import { basename, dirname, isAbsolute, join, resolve } from 'path';
12
+ import { askUserQuestion, notify } from '#infra';
13
+ import { isSensitiveProjectPath, isValidProjectPath } from '#path-guard';
14
+ import { readDeployedState, deployedStateProblemText, DEPLOYED_STATUS } from '#deployed-state';
15
+ import { initWorkspaceDatabase } from '#db-init';
16
+ import { findSurrealBinary, installSurreal, serverStatus } from '#surreal-server';
17
+ import {
18
+ findWorkspace,
19
+ isWorkspace,
20
+ formatLocalTime,
21
+ listGlobalWorkspaces,
22
+ readWorkspace,
23
+ resolvePath,
24
+ resolveWorkspace,
25
+ workspacePaths,
26
+ } from '#workspace';
9
27
 
10
28
  export const inject = ['commands', 'userQuestions'];
11
29
 
12
- const __dirname = dirname(fileURLToPath(import.meta.url));
13
- const SCRIPTS_DIR = join(__dirname, 'scripts');
14
-
15
- // ---------------------------------------------------------------------------
16
- // 路径工具
17
- // ---------------------------------------------------------------------------
18
-
19
- // 敏感系统路径前缀(黑名单):防止用户输入这些路径作为项目根
20
- const SENSITIVE_PATH_PREFIXES = [
21
- '/etc', '/proc', '/sys', '/dev',
22
- '/root/.ssh', '/root/.gnupg', '/root/.aws', '/root/.config',
23
- '/var/log', '/var/lib', '/boot',
24
- 'C:\\Windows', 'C:\\Program Files',
25
- ];
26
-
27
- function isValidPath(p) {
28
- if (!p || typeof p !== 'string') return false;
29
- if (p.includes('..')) return false;
30
- if (/[<>:"'|?*[\x00-\x1f]/.test(p)) return false;
31
- if (/\s/.test(p)) return false;
32
- // 拒绝以敏感路径前缀开头的绝对路径
33
- const normalized = p.replace(/\\/g, '/');
34
- for (const prefix of SENSITIVE_PATH_PREFIXES) {
35
- if (normalized === prefix || normalized.startsWith(prefix + '/')) return false;
30
+ function helpText() {
31
+ return `📁 工作区初始化 用法
32
+
33
+ /fancy-bootstrap 交互式指定工作区目录进行初始化
34
+ /fancy-bootstrap <目录> 在指定目录初始化工作区
35
+ /fancy-bootstrap status 查看当前工作区
36
+ /fancy-bootstrap list 列出本机记录的所有工作区
37
+ /fancy-bootstrap help 显示本帮助
38
+
39
+ 工作区结构:
40
+ <工作区>/projects/<UUID>/ 每个小说项目一个目录
41
+ <工作区>/扫榜结果/ 多个项目共享的扫榜产物
42
+ <工作区>/参考资料/ 待拆解的对标小说(fancy-analyze 读取)
43
+ <工作区>/对标/ 多个项目共享的对标分析
44
+ <工作区>/.fancy/ SurrealDB 数据、密钥、工作区配置
45
+
46
+ 初始化会创建 SurrealDB 数据目录、项目注册表、数据库校验函数和素材库 schema。
47
+ 启动时按 FANCY_SURREAL_BIN → <工作区>/.fancy/bin/surreal → PATH 的顺序查找 surreal;
48
+ 都没有时才尝试下载官方二进制到 <工作区>/.fancy/bin/。`;
49
+ }
50
+
51
+ function normalizePath(raw, session) {
52
+ let value = String(raw || '').trim();
53
+ if (!value) return null;
54
+ if (value === '~' || value.startsWith('~/')) value = join(homedir(), value.slice(1));
55
+ if (!isAbsolute(value)) {
56
+ const base = session?.header?.cwd ? resolve(session.header.cwd) : null;
57
+ if (!base) return null;
58
+ value = resolve(base, value);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ /**
64
+ * Resolve symlinks for an existing path, or for a not-yet-created path use the
65
+ * deepest existing ancestor and append the missing segments. This lets the
66
+ * sensitive-path check see through `/tmp/link -> /etc` even when the workspace
67
+ * directory itself does not exist yet.
68
+ */
69
+ function realTargetPath(target) {
70
+ let current = resolve(target);
71
+ const missing = [];
72
+ for (;;) {
73
+ try {
74
+ if (existsSync(current)) {
75
+ const real = realpathSync(current);
76
+ return missing.length ? join(real, ...missing) : real;
77
+ }
78
+ } catch {
79
+ return resolve(target);
80
+ }
81
+ const parent = dirname(current);
82
+ if (parent === current) return resolve(target);
83
+ missing.unshift(basename(current));
84
+ current = parent;
36
85
  }
37
- return true;
38
86
  }
39
87
 
40
- // ---------------------------------------------------------------------------
41
- // 注册
42
- // ---------------------------------------------------------------------------
88
+ function formatWorkspace(root) {
89
+ const workspace = readWorkspace(root);
90
+ const paths = workspacePaths(root);
91
+ return [
92
+ '📁 工作区:' + root,
93
+ '工作区 ID:' + workspace.workspace_id,
94
+ '创建时间:' + formatLocalTime(workspace.created_at),
95
+ '更新时间:' + formatLocalTime(workspace.updated_at),
96
+ '当前项目:' + (workspace.active_project || '未选择(可用 /fancy-project use <名字|UUID> 指定)'),
97
+ '项目目录:' + paths.projects,
98
+ '扫榜结果:' + paths.scan,
99
+ '对标分析:' + paths.analyze,
100
+ '参考资料:' + paths.referenceSources,
101
+ 'SurrealDB:' + (workspace.surreal?.url || '—'),
102
+ '素材库:' + (workspace.surreal?.material_database || 'material_library'),
103
+ '数据库目录:' + paths.surrealData,
104
+ ].join('\n');
105
+ }
43
106
 
44
107
  export async function apply(ctx) {
45
108
  ctx.commands.register({
46
109
  name: 'fancy-bootstrap',
47
- description: '📁 初始化项目根 ( usage: /fancy-bootstrap [项目根] )',
48
- input: {
49
- hint: '[项目根]',
50
- },
110
+ description: '📁 初始化 fancy-webnovel 工作区 ( usage: /fancy-bootstrap [目录] | status | list | help )',
111
+ input: { hint: '[目录] | status | list' },
51
112
  handler: async (invocation) => {
52
113
  const session = invocation.agent.session;
53
- const rawInput = invocation.rawInput ? invocation.rawInput.trim() : '';
54
- const { signal } = invocation;
114
+ const rawInput = String(invocation.rawInput || '').trim();
55
115
 
56
- let projectRoot = rawInput;
116
+ if (rawInput.toLowerCase() === 'help') return notify(session, 'success', helpText());
57
117
 
58
- // 无参数 → 弹窗
59
- if (!projectRoot) {
60
- // Check if signal is already aborted before asking
61
- if (signal?.aborted) {
62
- return notify(session, 'error', bootstrap.notify({ type: 'cancelled' }));
118
+ if (rawInput.toLowerCase() === 'status') {
119
+ try {
120
+ const root = resolveWorkspace({ cwd: session?.header?.cwd, required: true });
121
+ return notify(session, 'success', formatWorkspace(root) + '\n\n' + JSON.stringify(await serverStatus(root), null, 2));
122
+ } catch {
123
+ return notify(session, 'error', '❌ 当前目录不是 fancy-webnovel 工作区,无法查看状态。请运行 /fancy-bootstrap 初始化工作区,或者切换到其他工作区。');
63
124
  }
64
- // askUserQuestion 统一兜住 DSH 的错误码,永不抛异常
65
- const asked = await askUserQuestion(ctx, invocation, [bootstrap.askPath()]);
66
- if (!asked.ok) {
67
- return notify(session, 'error', bootstrap.notify({
68
- type: asked.failure.type,
69
- err: asked.failure.message
70
- }));
125
+ }
126
+
127
+ if (rawInput.toLowerCase() === 'list') {
128
+ let entries = listGlobalWorkspaces();
129
+ const current = findWorkspace(session?.header?.cwd);
130
+ if (!entries.length && !current) {
131
+ return notify(session, 'success', '🌐 本机未发现 fancy-webnovel 工作区。\n运行 /fancy-bootstrap <目录> 创建第一个工作区。');
71
132
  }
72
- const result = asked.answer;
73
- // Handle case where user skipped or X'd the question
74
- if (!result?.answers?.[0]) {
75
- return notify(session, 'error', bootstrap.notify({ type: 'cancelled' }));
133
+ let currentEntry = current ? entries.find((entry) => entry.workspace === current) : null;
134
+ if (current && !currentEntry) {
135
+ // A lightweight/legacy root may be usable without appearing in the
136
+ // registry yet. Keep it visible and put it first.
137
+ currentEntry = {
138
+ workspace: current,
139
+ workspace_id: '',
140
+ name: basename(current),
141
+ exists: isWorkspace(current),
142
+ last_used: '',
143
+ };
144
+ entries = [currentEntry, ...entries];
76
145
  }
77
- projectRoot = result.answers[0]?.custom?.trim() || '';
146
+ const ordered = currentEntry
147
+ ? [currentEntry, ...entries.filter((entry) => entry !== currentEntry)]
148
+ : entries;
149
+
150
+ const details = await Promise.all(ordered.map(async (entry) => {
151
+ let surreal = null;
152
+ try { surreal = readWorkspace(entry.workspace).surreal || null; } catch { /* missing/lightweight */ }
153
+ let status = null;
154
+ if (entry.exists && surreal) {
155
+ try { status = await serverStatus(entry.workspace); } catch { /* ignore probe errors */ }
156
+ }
157
+ return { entry, surreal, status };
158
+ }));
159
+
160
+ const lines = ['🌐 本机工作区(' + ordered.length + ')', ''];
161
+ for (const { entry, surreal, status } of details) {
162
+ lines.push((entry.workspace === current ? '* ' : '- ') + entry.name);
163
+ lines.push(' 路径:' + entry.workspace);
164
+ lines.push(' ID:' + (entry.workspace_id || '—') + (entry.exists ? '' : ' ⚠️ 目录不存在/已删除'));
165
+ if (surreal) {
166
+ lines.push(' SurrealDB:' + (surreal.url || '—'));
167
+ if (surreal.namespace || surreal.registry_database) {
168
+ lines.push(' 命名空间:' + (surreal.namespace || '—') + ' 注册表:' + (surreal.registry_database || '—'));
169
+ }
170
+ if (surreal.data_dir) lines.push(' 数据目录:' + surreal.data_dir);
171
+ if (status) {
172
+ if (status.running) {
173
+ const version = status.health?.version ? ' · ' + status.health.version : '';
174
+ const credentials = status.credentials_ok === false ? '(凭据不匹配)' : '';
175
+ lines.push(' 服务:运行中' + version + credentials);
176
+ } else {
177
+ lines.push(' 服务:未运行');
178
+ }
179
+ }
180
+ } else {
181
+ lines.push(' SurrealDB:—');
182
+ }
183
+ if (entry.last_used) lines.push(' 最近使用:' + formatLocalTime(entry.last_used));
184
+ lines.push('');
185
+ }
186
+ lines.push('* 表示当前 cwd 所在工作区。');
187
+ return notify(session, 'success', lines.join('\n'));
78
188
  }
79
189
 
80
- if (!projectRoot) {
81
- return notify(session, 'error', bootstrap.notify({ type: 'no_path' }));
190
+ // Only auto-report a workspace when the caller is actually inside one,
191
+ // or when FANCY_HOME explicitly points at one. The global pointer is a
192
+ // convenience for other commands, not a reason to hijack bootstrap from
193
+ // an unrelated directory.
194
+ const cwd = session?.header?.cwd;
195
+ let insideWorkspace = null;
196
+ if (process.env.FANCY_HOME) {
197
+ const envRoot = resolvePath(process.env.FANCY_HOME, cwd || process.cwd());
198
+ if (isWorkspace(envRoot)) insideWorkspace = envRoot;
199
+ }
200
+ if (!insideWorkspace && cwd) insideWorkspace = findWorkspace(cwd);
201
+ if (insideWorkspace && !rawInput) {
202
+ return notify(session, 'success', 'ℹ️ 当前目录已位于工作区:\n\n' + formatWorkspace(insideWorkspace));
82
203
  }
83
204
 
84
- if (projectRoot === '.') {
85
- // 不能回退到 process.cwd():那是宿主进程的工作目录,不是用户项目目录。
86
- // 宿主若恰好在某个含 .fancy-deployed 的目录里启动,会对错误的项目初始化。
87
- projectRoot = session.header?.cwd ?? null;
88
- if (!projectRoot) {
89
- return notify(session, 'error', bootstrap.notify({
90
- type: 'no_project_root',
91
- err: '无法确定项目目录(会话未提供 cwd)。请在项目目录下运行该命令。'
92
- }));
205
+ let target = rawInput ? normalizePath(rawInput, session) : null;
206
+ if (!rawInput) {
207
+ if (invocation.signal?.aborted) return notify(session, 'error', '🚫 操作已取消。');
208
+
209
+ const asked = await askUserQuestion(ctx, invocation, [{
210
+ id: 'workspace_path',
211
+ header: '选择工作区',
212
+ question: '选择或输入工作区目录',
213
+ options: (() => {
214
+ const cwd = session?.header?.cwd ? resolve(session.header.cwd) : null;
215
+ const items = [];
216
+ if (cwd) items.push({ label: cwd, description: '当前目录' });
217
+ for (const item of [
218
+ { label: join(homedir(), 'fancy-webnovel'), description: '用户主目录' },
219
+ { label: join(homedir(), 'Documents', 'fancy-webnovel'), description: '文档目录' },
220
+ ]) {
221
+ if (!items.some((entry) => entry.label === item.label)) items.push(item);
222
+ }
223
+ return items;
224
+ })(),
225
+ }]);
226
+ if (!asked.ok) {
227
+ if (asked.failure?.type === 'cancelled') return notify(session, 'error', '🚫 操作已取消。');
228
+ return notify(session, 'error', asked.failure?.message || '❌ 提问失败');
93
229
  }
94
- // Show notification but do NOT return - continue to confirmation step
95
- // (确认框里会带上完整路径,用户能看到)
96
- notify(session, 'success', bootstrap.notify({ type: 'using_cwd', projectRoot }));
230
+ const answer = asked.answer?.answers?.[0];
231
+ target = normalizePath(answer?.custom || answer?.selected?.[0] || '', session);
97
232
  }
98
233
 
99
- if (!isValidPath(projectRoot)) {
100
- return notify(session, 'error', bootstrap.notify({ type: 'invalid_path' }));
234
+ if (!target) return notify(session, 'error', '⚠️ 未提供工作区路径,操作已取消。');
235
+ const inputTarget = target;
236
+ const targetReal = realTargetPath(target);
237
+ if (isSensitiveProjectPath(targetReal) || !isValidProjectPath(targetReal)) {
238
+ return notify(session, 'error', '❌ 路径不合法或属于受保护目录:' + inputTarget
239
+ + (targetReal !== inputTarget ? '\n实际路径:' + targetReal : ''));
101
240
  }
241
+ target = targetReal;
242
+ try {
243
+ if (!statSync(target).isDirectory()) {
244
+ return notify(session, 'error', '❌ 工作区路径不是目录:' + target);
245
+ }
246
+ } catch { /* path does not exist yet; init will create it */ }
102
247
 
103
- // 文件**存在**不等于"已初始化"。
104
- // 之前只看存在性,于是文件损坏时也报"该目录已初始化"并拒绝执行 ——
105
- // 而别处(fancy-topic / fancy-scan)会告诉你"重新运行 /fancy-bootstrap 初始化",
106
- // 两条指引互相打架,用户被卡在死循环里出不来。
107
- const existingState = readDeployedState(projectRoot);
108
- if (existingState.status === 'ok') {
109
- return notify(session, 'error', bootstrap.notify({ type: 'already_initialized' }));
248
+ // A directory that already has .fancy-deployed is already initialized
249
+ // (full workspace or lightweight scan/topic root). A corrupt marker is
250
+ // reported explicitly instead of being overwritten or mistaken for a new
251
+ // workspace.
252
+ const deployed = readDeployedState(target);
253
+ if (deployed.status === DEPLOYED_STATUS.OK) {
254
+ return notify(session, 'error', '⚠️ 该目录已完成初始化,无需重复执行(.fancy-deployed 已存在):' + target);
110
255
  }
111
- if (existingState.status !== 'missing') {
112
- // 存在但坏了 / phase 不认识 → 明确说清并给出修复办法,
113
- // 而不是笼统地说"已初始化"(那会让用户以为一切正常)。
114
- return notify(session, 'error',
115
- deployedStateProblemText(existingState, { projectRoot })
116
- || bootstrap.notify({ type: 'already_initialized' }));
256
+ if (deployed.status !== DEPLOYED_STATUS.MISSING) {
257
+ return notify(session, 'error', deployedStateProblemText(deployed, { projectRoot: target })
258
+ || '❌ 状态文件 .fancy-deployed 无法读取:' + target);
117
259
  }
118
260
 
119
- // Check if signal is already aborted before asking confirm
120
- if (signal?.aborted) {
121
- return notify(session, 'error', bootstrap.notify({ type: 'cancelled' }));
122
- }
123
- // 注意:不要传 hideCustomInput —— DSH 的 AskUserQuestionItem 没有这个字段
124
- // (合法字段只有 id / question / detail / header / options / multiSelect / intent),
125
- // 传了会被运行时静默忽略,"只让点选项"的意图根本不生效。
126
- const asked = await askUserQuestion(ctx, invocation, [{
127
- id: 'confirm',
128
- question: bootstrap.notify({ type: 'confirm', projectRoot }),
129
- options: bootstrap.confirmOptions(),
261
+ if (invocation.signal?.aborted) return notify(session, 'error', '🚫 操作已取消。');
262
+ const confirmed = await askUserQuestion(ctx, invocation, [{
263
+ id: 'confirm_workspace',
264
+ header: '确认工作区',
265
+ question: '确认使用 ' + target + ' 作为 fancy-webnovel 工作区?',
266
+ options: [
267
+ { label: '确认创建', description: '创建/初始化工作区、数据库和校验函数' },
268
+ { label: '取消', description: '不做任何修改' },
269
+ ],
130
270
  }]);
131
- if (!asked.ok) {
132
- return notify(session, 'error', bootstrap.notify({
133
- type: asked.failure.type,
134
- err: asked.failure.message
135
- }));
136
- }
137
- const confirm = asked.answer;
138
- // Handle case where user skipped or X'd the question
139
- if (!confirm?.answers?.[0]) {
140
- return notify(session, 'error', bootstrap.notify({ type: 'cancelled' }));
141
- }
142
- const ans = confirm.answers[0];
143
- const chosen = ans?.selected?.[0] ?? ans?.custom;
144
- if (chosen !== '确认创建') {
145
- // 区分"点了取消"与"自己输入了别的东西":
146
- // 后者此前会被静默当成取消,用户不知道自己输入的内容被丢掉了。
147
- if (ans?.custom && !ans?.selected?.length) {
148
- return notify(session, 'error', bootstrap.notify({
149
- type: 'confirm_invalid',
150
- custom: ans.custom
151
- }));
152
- }
153
- return notify(session, 'error', bootstrap.notify({ type: 'cancelled' }));
271
+ if (!confirmed.ok) {
272
+ if (confirmed.failure?.type === 'cancelled') return notify(session, 'error', '🚫 操作已取消。');
273
+ return notify(session, 'error', confirmed.failure?.message || '❌ 提问失败');
154
274
  }
275
+ const choice = confirmed.answer?.answers?.[0]?.selected?.[0];
276
+ if (choice !== '确认创建') return notify(session, 'error', '🚫 操作已取消。');
155
277
 
156
- const initResult = await spawnScript('node', [join(SCRIPTS_DIR, 'init.js'), projectRoot], { timeout: 30000 });
157
-
158
- if (initResult.ok) {
159
- return notify(session, 'success', bootstrap.notify({ type: 'success' }));
160
- } else {
161
- const err = (typeof initResult.error === 'object' && initResult.error !== null)
162
- ? (initResult.error.message || JSON.stringify(initResult.error))
163
- : String(initResult.error || '未知错误');
164
- return notify(session, 'error', bootstrap.notify({ type: 'error', err }));
278
+ try {
279
+ // First init may need the standalone binary. Install it into the
280
+ // workspace so it survives PATH changes and is easy to remove.
281
+ const notes = [];
282
+ if (!findSurrealBinary(target)) {
283
+ notes.push('⏬ 未找到 surreal,已自动下载到工作区 .fancy/bin/。');
284
+ await installSurreal(target);
285
+ }
286
+ const result = await initWorkspaceDatabase(target);
287
+ return notify(session, 'success', [
288
+ '✅ 工作区初始化完成',
289
+ ...(notes.length ? ['', ...notes] : []),
290
+ '',
291
+ formatWorkspace(result.workspace),
292
+ '',
293
+ '数据库:schema ' + result.schema_version + ' · validation ' + result.validation_version,
294
+ '素材库:' + (result.material?.database || 'material_library') + '(schema ' + (result.material?.schema_version || 6) + ')',
295
+ '服务:' + (result.server.running ? '运行中' : '未运行'),
296
+ ...(result.started && result.started.endpoint_changed
297
+ ? ['⚠️ 原端口被另一个 SurrealDB 占用,已改用:' + result.started.endpoint]
298
+ : []),
299
+ '',
300
+ '下一步:',
301
+ '1. /fancy-scan 扫榜',
302
+ '2. /fancy-topic 选题',
303
+ '3. /fancy-topic setup 创建项目(会分配 UUID、项目库和独立数据库用户)',
304
+ '4. /fancy-material 生成素材库(可选,写作时查名字/金手指/冲突/场景)',
305
+ '5. /fancy-douyin add <主页> 采集抖音图文素材(可选)',
306
+ '6. /fancy-build scale 开始写故事',
307
+ ].join('\n'));
308
+ } catch (err) {
309
+ return notify(session, 'error', '❌ 初始化失败:' + (err && err.message ? err.message : String(err)));
165
310
  }
166
- }
311
+ },
167
312
  });
168
313
  }
@@ -20,9 +20,11 @@
20
20
  * 启动时检查 topic_decision_<date>.md 文件是否存在;不存在则报错让用户
21
21
  * 先跑 fancy-scan 并等待 LLM 完成分析写入该文件。
22
22
  */
23
- import { writeFileSync, existsSync, mkdirSync, realpathSync, statSync, unlinkSync } from 'fs';
23
+ import { existsSync, lstatSync, mkdirSync, realpathSync, statSync, unlinkSync } from 'fs';
24
24
  import { expandHome, resolvePath, joinPath } from './path-utils.js';
25
25
  import { EC, errorResponse } from '#error-codes';
26
+ import { atomicWriteFile } from '#atomic-write';
27
+ import { isValidProjectPath, isSensitiveProjectPath, projectPathProblem } from '#path-guard';
26
28
 
27
29
  function nowIso() {
28
30
  return new Date().toISOString().replace('.000Z', 'Z');
@@ -56,17 +58,15 @@ function fail(ec, extra = {}) {
56
58
  function main() {
57
59
  const projectRootRaw = process.argv[2] ?? '';
58
60
 
59
- if (!projectRootRaw || projectRootRaw.includes('..') || /[<>:"'|?*]/.test(projectRootRaw) || /[\x00-\x1f]/.test(projectRootRaw)) {
60
- fail(EC.EC001_INVALID_PATH);
61
+ // 路径校验与 plugins/fancy-bootstrap/index.js 用**同一套规则**
62
+ // (lib/path-guard.js)。此前两边不一致:index.js 拒空白、init.js 不拒;
63
+ // 两边都放行 shell 元字符($ ` ; & |)—— 而路径会被原样拼进
64
+ // 提示词里的 shell 命令,双引号挡不住命令替换。
65
+ if (isSensitiveProjectPath(projectRootRaw)) {
66
+ fail(EC.EC019_SENSITIVE_PATH);
61
67
  }
62
-
63
- // 二次校验:拒绝敏感系统路径
64
- const SENSITIVE_PREFIXES = ['/etc', '/proc', '/sys', '/dev', '/root/.ssh', '/root/.gnupg', '/var/log', '/var/lib', '/boot'];
65
- const normalized = projectRootRaw.replace(/\\/g, '/');
66
- for (const prefix of SENSITIVE_PREFIXES) {
67
- if (normalized === prefix || normalized.startsWith(prefix + '/')) {
68
- fail(EC.EC019_SENSITIVE_PATH);
69
- }
68
+ if (!isValidProjectPath(projectRootRaw)) {
69
+ fail(EC.EC001_INVALID_PATH, { message: projectPathProblem(projectRootRaw) || '路径不合法' });
70
70
  }
71
71
 
72
72
  const projectRoot = resolvePath(expandHome(projectRootRaw));
@@ -81,21 +81,38 @@ function main() {
81
81
  const projectRootReal = realPathOf(projectRoot);
82
82
  const deployed = joinPath(projectRootReal, '.fancy-deployed');
83
83
 
84
- if (existsSync(deployed)) {
84
+ // 标记文件必须是普通文件;符号链接会把“写项目状态”变成“写链接指向的文件”,
85
+ // 而项目根是用户可写的 —— 一个坏链接就能把内容写到项目外。
86
+ let deployedStat = null;
87
+ try { deployedStat = lstatSync(deployed); } catch { /* 不存在 */ }
88
+ if (deployedStat) {
89
+ if (deployedStat.isSymbolicLink()) {
90
+ fail(EC.EC017_INIT_FAILED, {
91
+ message: '.fancy-deployed 是符号链接,拒绝写入;请删除该链接后重新初始化'
92
+ });
93
+ }
94
+ if (!deployedStat.isFile()) {
95
+ fail(EC.EC017_INIT_FAILED, {
96
+ message: '.fancy-deployed 已存在但不是普通文件,请先处理它再重新初始化'
97
+ });
98
+ }
85
99
  fail(EC.EC002_ALREADY_INITIALIZED);
86
100
  }
87
101
  if (!existsSync(projectRootReal)) {
88
- fail(EC.EC018_DIR_NOT_FOUND, { message: `目录不存在: ${projectRoot}` });
102
+ fail(EC.EC018_DIR_NOT_FOUND, {
103
+ message: `项目根目录不存在: ${projectRoot}`,
104
+ reason: 'missing',
105
+ });
89
106
  }
90
107
  if (!isDirectory(projectRootReal)) {
91
- fail(EC.EC018_DIR_NOT_FOUND, { message: `不是一个目录: ${projectRoot}` });
108
+ fail(EC.EC018_DIR_NOT_FOUND, {
109
+ message: `项目根路径不是目录: ${projectRoot}`,
110
+ reason: 'not_directory',
111
+ });
92
112
  }
93
113
  // 真实路径再查一遍敏感前缀(符号链接已经解开)
94
- const realNormalized = projectRootReal.replace(/\\/g, '/');
95
- for (const prefix of SENSITIVE_PREFIXES) {
96
- if (realNormalized === prefix || realNormalized.startsWith(prefix + '/')) {
97
- fail(EC.EC019_SENSITIVE_PATH);
98
- }
114
+ if (isSensitiveProjectPath(projectRootReal)) {
115
+ fail(EC.EC019_SENSITIVE_PATH);
99
116
  }
100
117
 
101
118
  const now = nowIso();
@@ -107,12 +124,29 @@ function main() {
107
124
  // 而项目其实是个半初始化状态(这次失败、那次也失败)。
108
125
  // 现在标记文件是"最后一步",失败就不会留下误导性的标记。
109
126
  //
110
- // 预创建工具目录,方便 fancy-analyze 等插件直接使用
111
- mkdirSync(joinPath(projectRootReal, '参考资料'), { recursive: true });
112
- mkdirSync(joinPath(projectRootReal, '对标'), { recursive: true });
127
+ // 预创建工具目录,方便 fancy-analyze 等插件直接使用。
128
+ // 这两个目录后续会被其它插件写入(原文/拆解结果),不能是符号链接 ——
129
+ // 否则一次初始化就能把它们指到项目外。
130
+ for (const name of ['参考资料', '对标']) {
131
+ const p = joinPath(projectRootReal, name);
132
+ let st = null;
133
+ try { st = lstatSync(p); } catch { /* 不存在 */ }
134
+ if (st) {
135
+ if (st.isSymbolicLink()) {
136
+ throw new Error(`${name}/ 是符号链接,拒绝初始化;请换成真实目录`);
137
+ }
138
+ if (!st.isDirectory()) {
139
+ throw new Error(`${name}/ 已存在但不是目录,拒绝初始化`);
140
+ }
141
+ } else {
142
+ mkdirSync(p, { recursive: true });
143
+ }
144
+ }
113
145
 
114
- // 写 .fancy-deployed 标记文件
115
- writeFileSync(deployed, JSON.stringify({
146
+ // 写 .fancy-deployed 标记文件。
147
+ // 原子写 + 0600:里面有 llm_wiki_token,不能让同机其它用户读到;
148
+ // 也不能在写到一半被杀时留下半份 JSON(下次启动会报“状态文件损坏”)。
149
+ atomicWriteFile(deployed, JSON.stringify({
116
150
  deployed_at: now,
117
151
  project_root_abs: projectRootReal,
118
152
  phase: 'initialized',
@@ -121,7 +155,7 @@ function main() {
121
155
  reference_id: '',
122
156
  novel_id: '',
123
157
  provider: 'custom',
124
- }, null, 2), 'utf-8');
158
+ }, null, 2), { mode: 0o600 });
125
159
  } catch (e) {
126
160
  const errMsg = e instanceof Error ? e.message : String(e);
127
161
  // 失败时把标记文件删掉,别留半初始化状态