@nostale/cli 0.1.0

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 (174) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/assets/docker-compose.server.yml +70 -0
  4. package/dist/commands/config/generation.js +23 -0
  5. package/dist/commands/config/providers.js +138 -0
  6. package/dist/commands/config/sources.js +145 -0
  7. package/dist/commands/config/templates.js +117 -0
  8. package/dist/commands/config/tokens.js +83 -0
  9. package/dist/commands/config.js +43 -0
  10. package/dist/commands/doctor.js +98 -0
  11. package/dist/commands/setup.js +5 -0
  12. package/dist/commands/start.js +23 -0
  13. package/dist/commands/status.js +17 -0
  14. package/dist/commands/stop.js +16 -0
  15. package/dist/index.js +23 -0
  16. package/dist/lib/api-config.js +21 -0
  17. package/dist/lib/env-writer.js +41 -0
  18. package/dist/lib/http.js +117 -0
  19. package/dist/lib/install-root.js +75 -0
  20. package/dist/lib/paths.js +12 -0
  21. package/dist/lib/ports.js +19 -0
  22. package/dist/lib/process-manager.js +151 -0
  23. package/dist/lib/repo-root.js +34 -0
  24. package/dist/lib/wizard.js +267 -0
  25. package/dist/tui/prompts.js +54 -0
  26. package/package.json +69 -0
  27. package/services/mcp-server/index.cjs +48326 -0
  28. package/services/migrations/001_init_rag_v1.sql +128 -0
  29. package/services/migrations/002_doc_lifecycle.sql +96 -0
  30. package/services/migrations/004_mcp_tokens.sql +11 -0
  31. package/services/migrations/005_settings.sql +28 -0
  32. package/services/migrations/006_document_sources.sql +14 -0
  33. package/services/migrations/007_generation_settings.sql +9 -0
  34. package/services/migrations/008_llm_provider_configs_unique.sql +5 -0
  35. package/services/wasm/tree-sitter-c.wasm +0 -0
  36. package/services/wasm/tree-sitter-c_sharp.wasm +0 -0
  37. package/services/wasm/tree-sitter-cpp.wasm +0 -0
  38. package/services/wasm/tree-sitter-dart.wasm +0 -0
  39. package/services/wasm/tree-sitter-go.wasm +0 -0
  40. package/services/wasm/tree-sitter-java.wasm +0 -0
  41. package/services/wasm/tree-sitter-javascript.wasm +0 -0
  42. package/services/wasm/tree-sitter-kotlin.wasm +0 -0
  43. package/services/wasm/tree-sitter-php.wasm +0 -0
  44. package/services/wasm/tree-sitter-python.wasm +0 -0
  45. package/services/wasm/tree-sitter-rust.wasm +0 -0
  46. package/services/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/services/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/services/wasm/tree-sitter.wasm +0 -0
  49. package/services/wiki-api/index.cjs +82370 -0
  50. package/services/wiki-web/.output/nitro.json +15 -0
  51. package/services/wiki-web/.output/public/_nuxt/68caad34b1e80fa12b3a.4Cpu5VkB.woff +0 -0
  52. package/services/wiki-web/.output/public/_nuxt/6b042d50787d4a81d659.D7JGeKLp.svg +1835 -0
  53. package/services/wiki-web/.output/public/_nuxt/9065bfe481020b04b68a.CMveUrS1.woff2 +0 -0
  54. package/services/wiki-web/.output/public/_nuxt/B5j-AdCY.js +1 -0
  55. package/services/wiki-web/.output/public/_nuxt/BAgZ2ZFT.js +1 -0
  56. package/services/wiki-web/.output/public/_nuxt/BcxnhBGC.js +1 -0
  57. package/services/wiki-web/.output/public/_nuxt/BeIDAJKt.js +1 -0
  58. package/services/wiki-web/.output/public/_nuxt/BebqDWPm.js +1 -0
  59. package/services/wiki-web/.output/public/_nuxt/CFx_Wb7e.js +1 -0
  60. package/services/wiki-web/.output/public/_nuxt/CSEkmdhK.js +1 -0
  61. package/services/wiki-web/.output/public/_nuxt/CXYMfke_.js +1 -0
  62. package/services/wiki-web/.output/public/_nuxt/Cap3hpB7.js +1 -0
  63. package/services/wiki-web/.output/public/_nuxt/Ckd7UvSj.js +3 -0
  64. package/services/wiki-web/.output/public/_nuxt/D0DJQL09.js +1 -0
  65. package/services/wiki-web/.output/public/_nuxt/DRDlUXmk.js +66 -0
  66. package/services/wiki-web/.output/public/_nuxt/DW42QLqA.js +1 -0
  67. package/services/wiki-web/.output/public/_nuxt/DdDtdYim.js +1 -0
  68. package/services/wiki-web/.output/public/_nuxt/Dm1y7TW1.js +1 -0
  69. package/services/wiki-web/.output/public/_nuxt/DptiLPkM.js +4 -0
  70. package/services/wiki-web/.output/public/_nuxt/QFl-TMXl.js +1 -0
  71. package/services/wiki-web/.output/public/_nuxt/_id_.JvKV2jiN.css +1 -0
  72. package/services/wiki-web/.output/public/_nuxt/a9c9056a643325579e2a.CCNAgZDa.ttf +0 -0
  73. package/services/wiki-web/.output/public/_nuxt/bojElwT6.js +1 -0
  74. package/services/wiki-web/.output/public/_nuxt/builds/latest.json +1 -0
  75. package/services/wiki-web/.output/public/_nuxt/builds/meta/f68e5546-2919-478b-b4ad-702826e8383c.json +1 -0
  76. package/services/wiki-web/.output/public/_nuxt/d495aba62101f856c80f.DJ4Lg1TH.eot +0 -0
  77. package/services/wiki-web/.output/public/_nuxt/entry.isoIPAS3.css +1 -0
  78. package/services/wiki-web/.output/public/_nuxt/f8QtLg7g.js +1 -0
  79. package/services/wiki-web/.output/public/_nuxt/nhMvlSQm.js +1 -0
  80. package/services/wiki-web/.output/public/_nuxt/pFBugkEL.js +1 -0
  81. package/services/wiki-web/.output/public/_nuxt/plus-jakarta-sans-latin-ext-wght-normal.DmpS2jIq.woff2 +0 -0
  82. package/services/wiki-web/.output/public/_nuxt/plus-jakarta-sans-latin-wght-normal.eXO_dkmS.woff2 +0 -0
  83. package/services/wiki-web/.output/public/_nuxt/plus-jakarta-sans-vietnamese-wght-normal.qRpaaN48.woff2 +0 -0
  84. package/services/wiki-web/.output/public/favicon.svg +6 -0
  85. package/services/wiki-web/.output/public/robots.txt +2 -0
  86. package/services/wiki-web/.output/server/chunks/_/error-500.mjs +19 -0
  87. package/services/wiki-web/.output/server/chunks/_/error-500.mjs.map +1 -0
  88. package/services/wiki-web/.output/server/chunks/build/client.precomputed.mjs +4 -0
  89. package/services/wiki-web/.output/server/chunks/build/client.precomputed.mjs.map +1 -0
  90. package/services/wiki-web/.output/server/chunks/nitro/nitro.mjs +5352 -0
  91. package/services/wiki-web/.output/server/chunks/nitro/nitro.mjs.map +1 -0
  92. package/services/wiki-web/.output/server/chunks/routes/api-proxy/_...path_.mjs +34 -0
  93. package/services/wiki-web/.output/server/chunks/routes/api-proxy/_...path_.mjs.map +1 -0
  94. package/services/wiki-web/.output/server/chunks/routes/renderer.mjs +369 -0
  95. package/services/wiki-web/.output/server/chunks/routes/renderer.mjs.map +1 -0
  96. package/services/wiki-web/.output/server/chunks/virtual/_virtual_spa-template.mjs +4 -0
  97. package/services/wiki-web/.output/server/chunks/virtual/_virtual_spa-template.mjs.map +1 -0
  98. package/services/wiki-web/.output/server/index.mjs +10 -0
  99. package/services/wiki-web/.output/server/index.mjs.map +1 -0
  100. package/services/wiki-web/.output/server/node_modules/@babel/parser/lib/index.js +14599 -0
  101. package/services/wiki-web/.output/server/node_modules/@babel/parser/package.json +50 -0
  102. package/services/wiki-web/.output/server/node_modules/@vue/compiler-core/dist/compiler-core.cjs.prod.js +6759 -0
  103. package/services/wiki-web/.output/server/node_modules/@vue/compiler-core/package.json +58 -0
  104. package/services/wiki-web/.output/server/node_modules/@vue/compiler-dom/dist/compiler-dom.cjs.prod.js +689 -0
  105. package/services/wiki-web/.output/server/node_modules/@vue/compiler-dom/package.json +57 -0
  106. package/services/wiki-web/.output/server/node_modules/@vue/compiler-ssr/dist/compiler-ssr.cjs.js +1413 -0
  107. package/services/wiki-web/.output/server/node_modules/@vue/compiler-ssr/package.json +34 -0
  108. package/services/wiki-web/.output/server/node_modules/@vue/reactivity/dist/reactivity.cjs.prod.js +1899 -0
  109. package/services/wiki-web/.output/server/node_modules/@vue/reactivity/package.json +55 -0
  110. package/services/wiki-web/.output/server/node_modules/@vue/runtime-core/dist/runtime-core.cjs.prod.js +6940 -0
  111. package/services/wiki-web/.output/server/node_modules/@vue/runtime-core/package.json +52 -0
  112. package/services/wiki-web/.output/server/node_modules/@vue/runtime-dom/dist/runtime-dom.cjs.prod.js +1822 -0
  113. package/services/wiki-web/.output/server/node_modules/@vue/runtime-dom/package.json +60 -0
  114. package/services/wiki-web/.output/server/node_modules/@vue/server-renderer/dist/server-renderer.cjs.prod.js +896 -0
  115. package/services/wiki-web/.output/server/node_modules/@vue/server-renderer/package.json +55 -0
  116. package/services/wiki-web/.output/server/node_modules/@vue/shared/dist/shared.cjs.prod.js +604 -0
  117. package/services/wiki-web/.output/server/node_modules/@vue/shared/package.json +47 -0
  118. package/services/wiki-web/.output/server/node_modules/devalue/index.js +4 -0
  119. package/services/wiki-web/.output/server/node_modules/devalue/package.json +40 -0
  120. package/services/wiki-web/.output/server/node_modules/devalue/src/base64.js +60 -0
  121. package/services/wiki-web/.output/server/node_modules/devalue/src/constants.js +12 -0
  122. package/services/wiki-web/.output/server/node_modules/devalue/src/parse.js +283 -0
  123. package/services/wiki-web/.output/server/node_modules/devalue/src/stringify.js +401 -0
  124. package/services/wiki-web/.output/server/node_modules/devalue/src/uneval.js +552 -0
  125. package/services/wiki-web/.output/server/node_modules/devalue/src/utils.js +160 -0
  126. package/services/wiki-web/.output/server/node_modules/entities/dist/commonjs/decode-codepoint.js +77 -0
  127. package/services/wiki-web/.output/server/node_modules/entities/dist/commonjs/decode.js +568 -0
  128. package/services/wiki-web/.output/server/node_modules/entities/dist/commonjs/generated/decode-data-html.js +7 -0
  129. package/services/wiki-web/.output/server/node_modules/entities/dist/commonjs/generated/decode-data-xml.js +7 -0
  130. package/services/wiki-web/.output/server/node_modules/entities/dist/commonjs/internal/bin-trie-flags.js +21 -0
  131. package/services/wiki-web/.output/server/node_modules/entities/dist/commonjs/internal/decode-shared.js +31 -0
  132. package/services/wiki-web/.output/server/node_modules/entities/dist/commonjs/package.json +3 -0
  133. package/services/wiki-web/.output/server/node_modules/entities/package.json +120 -0
  134. package/services/wiki-web/.output/server/node_modules/estree-walker/dist/umd/estree-walker.js +344 -0
  135. package/services/wiki-web/.output/server/node_modules/estree-walker/package.json +37 -0
  136. package/services/wiki-web/.output/server/node_modules/hookable/dist/index.mjs +257 -0
  137. package/services/wiki-web/.output/server/node_modules/hookable/package.json +53 -0
  138. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/array-set.js +121 -0
  139. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/base64-vlq.js +140 -0
  140. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/base64.js +67 -0
  141. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/binary-search.js +111 -0
  142. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/mapping-list.js +79 -0
  143. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/quick-sort.js +132 -0
  144. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/source-map-consumer.js +1188 -0
  145. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/source-map-generator.js +444 -0
  146. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/source-node.js +413 -0
  147. package/services/wiki-web/.output/server/node_modules/source-map-js/lib/util.js +594 -0
  148. package/services/wiki-web/.output/server/node_modules/source-map-js/package.json +71 -0
  149. package/services/wiki-web/.output/server/node_modules/source-map-js/source-map.js +8 -0
  150. package/services/wiki-web/.output/server/node_modules/ufo/dist/index.mjs +645 -0
  151. package/services/wiki-web/.output/server/node_modules/ufo/package.json +48 -0
  152. package/services/wiki-web/.output/server/node_modules/unhead/dist/parser.mjs +508 -0
  153. package/services/wiki-web/.output/server/node_modules/unhead/dist/plugins.mjs +101 -0
  154. package/services/wiki-web/.output/server/node_modules/unhead/dist/server.mjs +180 -0
  155. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.B3VbJo7Y.mjs +207 -0
  156. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.BYvz9V1x.mjs +43 -0
  157. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.CaI5ZD4O.mjs +182 -0
  158. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.CbpEuj3y.mjs +71 -0
  159. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.D4vSFytZ.mjs +236 -0
  160. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.DQc16pHI.mjs +196 -0
  161. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.TxTMM7cM.mjs +166 -0
  162. package/services/wiki-web/.output/server/node_modules/unhead/dist/shared/unhead.yem5I2v_.mjs +38 -0
  163. package/services/wiki-web/.output/server/node_modules/unhead/dist/utils.mjs +5 -0
  164. package/services/wiki-web/.output/server/node_modules/unhead/package.json +105 -0
  165. package/services/wiki-web/.output/server/node_modules/vue/dist/vue.cjs.js +80 -0
  166. package/services/wiki-web/.output/server/node_modules/vue/dist/vue.cjs.prod.js +66 -0
  167. package/services/wiki-web/.output/server/node_modules/vue/index.js +7 -0
  168. package/services/wiki-web/.output/server/node_modules/vue/index.mjs +1 -0
  169. package/services/wiki-web/.output/server/node_modules/vue/package.json +112 -0
  170. package/services/wiki-web/.output/server/node_modules/vue/server-renderer/index.mjs +1 -0
  171. package/services/wiki-web/.output/server/node_modules/vue-bundle-renderer/dist/runtime.mjs +249 -0
  172. package/services/wiki-web/.output/server/node_modules/vue-bundle-renderer/package.json +57 -0
  173. package/services/wiki-web/.output/server/package.json +26 -0
  174. package/services/wiki-worker/index.cjs +66661 -0
@@ -0,0 +1,151 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { waitForHttp } from './http.js';
5
+ import { migrationsDir, resolveServiceSource, wasmDir } from './install-root.js';
6
+ import { LOG_DIR, PID_DIR } from './paths.js';
7
+ // wiki-api/wiki-worker/mcp-server run from source via tsx (no build step for the apps
8
+ // themselves), but they import compiled workspace packages through node_modules resolution —
9
+ // tsx does not transpile those on the fly. Without a prior `tsc` build, wiki-api crashes on boot
10
+ // with MODULE_NOT_FOUND for `@nostale/rag-core`. Bundled installs (M21) inline these packages
11
+ // into the service bundle itself, so this only applies in dev mode.
12
+ const WORKSPACE_PACKAGES = ['extractor', 'rag-core'];
13
+ function ensureWorkspacePackagesBuilt(repoRoot) {
14
+ const unbuilt = WORKSPACE_PACKAGES.filter((pkg) => !existsSync(join(repoRoot, 'packages', pkg, 'dist', 'index.js')));
15
+ if (unbuilt.length === 0)
16
+ return;
17
+ execFileSync('npm', ['run', 'build', ...unbuilt.map((pkg) => `--workspace=@nostale/${pkg}`)], { cwd: repoRoot, stdio: 'inherit' });
18
+ }
19
+ // Personal mode runs sibling apps as plain child processes (no Docker) — each spawned directly,
20
+ // assumes a Unix-like host (macOS/Linux). What actually gets spawned (bundled `node index.cjs`
21
+ // vs. dev-mode `tsx src/index.ts`) is resolved per-call via resolveServiceSource (install-root.ts).
22
+ export const SERVICES = [
23
+ { name: 'wiki-api', portEnvKey: 'WIKI_API_PORT' },
24
+ { name: 'wiki-worker', portEnvKey: 'WIKI_WORKER_PORT' },
25
+ { name: 'mcp-server', portEnvKey: 'MCP_SERVER_PORT' },
26
+ { name: 'wiki-web', portEnvKey: 'WIKI_WEB_PORT' },
27
+ ];
28
+ /**
29
+ * M18 — wiki-web is the only service that can be opted out of via WIKI_WEB_ENABLED='0' (set by
30
+ * the wizard's "Install Web UI?" step). Missing/any-other-value means enabled (default Yes).
31
+ * Callers already read `.env` before calling into this module (status.ts/doctor.ts/stop.ts/
32
+ * start.ts/wizard.ts all do), so threading `env` through as a plain param avoids this module
33
+ * reading `.env` itself — no new layering violation, no duplicated on/off logic per caller.
34
+ */
35
+ export function isServiceEnabled(name, env) {
36
+ if (name !== 'wiki-web')
37
+ return true;
38
+ return env.WIKI_WEB_ENABLED !== '0';
39
+ }
40
+ function ensureDirs() {
41
+ mkdirSync(PID_DIR, { recursive: true });
42
+ mkdirSync(LOG_DIR, { recursive: true });
43
+ }
44
+ function pidFile(name) {
45
+ return join(PID_DIR, `${name}.pid`);
46
+ }
47
+ function logFile(name) {
48
+ return join(LOG_DIR, `${name}.log`);
49
+ }
50
+ function readPid(name) {
51
+ const file = pidFile(name);
52
+ if (!existsSync(file))
53
+ return null;
54
+ const pid = Number(readFileSync(file, 'utf-8').trim());
55
+ return Number.isInteger(pid) ? pid : null;
56
+ }
57
+ export function isRunning(pid) {
58
+ try {
59
+ process.kill(pid, 0);
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ export function statusService(name) {
67
+ const pid = readPid(name);
68
+ const running = pid !== null && isRunning(pid);
69
+ return { name, running, pid: running ? pid : null, disabled: false };
70
+ }
71
+ export function statusAll(env = {}) {
72
+ return SERVICES.map((s) => (isServiceEnabled(s.name, env) ? statusService(s.name) : { name: s.name, running: false, pid: null, disabled: true }));
73
+ }
74
+ /**
75
+ * Spawns and tracks a service given an already-resolved command (see resolveServiceSource in
76
+ * install-root.ts) — kept decoupled from that resolution so it stays trivially unit-testable
77
+ * with a fake source instead of a real InstallContext.
78
+ */
79
+ export function startService(name, source, port, env, opts = {}) {
80
+ ensureDirs();
81
+ const existing = statusService(name);
82
+ if (existing.running && existing.pid !== null)
83
+ return existing.pid;
84
+ const { cmd, args, cwd } = source;
85
+ // Explicit restrictive mode — the default 0o644 left service logs (which can contain sensitive
86
+ // output) world-readable (SECURITY-REVIEW-2026-07-10.md F-14). The mode only applies at
87
+ // creation, so chmod unconditionally too in case an older log file was left looser.
88
+ const logPath = logFile(name);
89
+ const logFd = openSync(logPath, 'a', 0o600);
90
+ chmodSync(logPath, 0o600);
91
+ const spawnEnv = { ...process.env, ...env, PORT: String(port) };
92
+ // Bundled mode inlined tree-sitter-wasms and rag-core's `.sql` migrations into the service
93
+ // bundle rather than leaving them on disk relative to the package — packages/extractor's
94
+ // parser and rag-core's migrate.ts each need an explicit pointer to where the bundler actually
95
+ // copied their assets (M21).
96
+ if (opts.wasmDir)
97
+ spawnEnv.NOSTALE_WASM_DIR = opts.wasmDir;
98
+ if (opts.migrationsDir)
99
+ spawnEnv.NOSTALE_MIGRATIONS_DIR = opts.migrationsDir;
100
+ const child = spawn(cmd, args, {
101
+ cwd,
102
+ env: spawnEnv,
103
+ detached: true,
104
+ stdio: ['ignore', logFd, logFd],
105
+ });
106
+ child.unref();
107
+ writeFileSync(pidFile(name), String(child.pid));
108
+ return child.pid;
109
+ }
110
+ /** Returns whether a running process was actually found and signaled. */
111
+ export function stopService(name) {
112
+ const status = statusService(name);
113
+ rmSync(pidFile(name), { force: true });
114
+ if (!status.running || status.pid === null)
115
+ return false;
116
+ process.kill(status.pid, 'SIGTERM');
117
+ return true;
118
+ }
119
+ export function stopAll(env = {}) {
120
+ return SERVICES.filter((s) => isServiceEnabled(s.name, env)).map((s) => ({ name: s.name, stopped: stopService(s.name) }));
121
+ }
122
+ /**
123
+ * wiki-api runs rag-core's migrations on boot; wiki-worker/mcp-server/wiki-web query that same
124
+ * file: DB immediately on their own boot with no migration of their own. Starting them before
125
+ * wiki-api's migration finishes races an empty schema (crashes with "no such table"). Bring
126
+ * wiki-api up and healthy first, then the rest — used by both `nostale setup` (restart: true,
127
+ * so config changes take effect) and `nostale start` (restart: false, idempotent no-op on
128
+ * already-running services).
129
+ */
130
+ export async function startAllPersonal(install, env, ports, opts = {}) {
131
+ const restart = opts.restart ?? true;
132
+ const wikiApi = SERVICES.find((s) => s.name === 'wiki-api');
133
+ if (!wikiApi)
134
+ throw new Error('wiki-api missing from SERVICES');
135
+ // Bundled services inline their workspace package deps via esbuild (M21) — nothing to build.
136
+ if (install.mode === 'dev')
137
+ ensureWorkspacePackagesBuilt(install.repoRoot);
138
+ const bundledAssetOpts = install.mode === 'bundled' ? { wasmDir: wasmDir(), migrationsDir: migrationsDir() } : {};
139
+ const spawnOne = (service, port) => startService(service.name, resolveServiceSource(install, service.name, port), port, env, bundledAssetOpts);
140
+ const started = [];
141
+ if (restart)
142
+ stopService(wikiApi.name);
143
+ started.push({ name: wikiApi.name, pid: spawnOne(wikiApi, ports[wikiApi.name]) });
144
+ await waitForHttp(`http://localhost:${ports[wikiApi.name]}`);
145
+ for (const service of SERVICES.filter((s) => s.name !== 'wiki-api' && isServiceEnabled(s.name, env))) {
146
+ if (restart)
147
+ stopService(service.name);
148
+ started.push({ name: service.name, pid: spawnOne(service, ports[service.name]) });
149
+ }
150
+ return started;
151
+ }
@@ -0,0 +1,34 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ export class RepoRootNotFoundError extends Error {
4
+ constructor() {
5
+ super('Could not find the NoStale repo root. Run `nostale setup` from inside a clone of the `nostale` repo (or a subdirectory of it).');
6
+ this.name = 'RepoRootNotFoundError';
7
+ }
8
+ }
9
+ /**
10
+ * apps/cli manages sibling apps (wiki-api, wiki-worker, wiki-web, mcp-server) as local child
11
+ * processes for personal mode — it operates on a `nostale` monorepo checkout rather than a
12
+ * bundled standalone binary, so it must locate that checkout's root from cwd.
13
+ */
14
+ export function findRepoRoot(startDir) {
15
+ let dir = startDir;
16
+ for (let i = 0; i < 20; i++) {
17
+ const pkgPath = join(dir, 'package.json');
18
+ if (existsSync(pkgPath)) {
19
+ try {
20
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
21
+ if (pkg.name === 'nostale' && Array.isArray(pkg.workspaces))
22
+ return dir;
23
+ }
24
+ catch {
25
+ // malformed package.json along the way — keep walking up
26
+ }
27
+ }
28
+ const parent = dirname(dir);
29
+ if (parent === dir)
30
+ break;
31
+ dir = parent;
32
+ }
33
+ throw new RepoRootNotFoundError();
34
+ }
@@ -0,0 +1,267 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { promptConfirm, promptPassword, promptSelect, promptText, printInfo, printStep } from '../tui/prompts.js';
5
+ import { readEnvFile, writeEnvFile, generateEncryptionKey } from './env-writer.js';
6
+ import { waitForHttp } from './http.js';
7
+ import { bundledServiceEntry, cliPackageRoot, cliVersion, installDir } from './install-root.js';
8
+ import { envFilePath, DATA_DIR, DEFAULT_DB_PATH, DEFAULT_WORK_DIR } from './paths.js';
9
+ import { pickPort } from './ports.js';
10
+ import { startAllPersonal } from './process-manager.js';
11
+ async function askLlmConfig(existing) {
12
+ const provider = await promptSelect('LLM provider', [
13
+ { label: 'Anthropic', value: 'anthropic' },
14
+ { label: 'OpenAI-compatible (self-hosted / proxy)', value: 'openai_compatible' },
15
+ ]);
16
+ const apiKey = await promptPassword('LLM API key', { defaultValue: existing.LLM_API_KEY });
17
+ const baseUrl = provider === 'openai_compatible'
18
+ ? await promptText('LLM base URL', { defaultValue: existing.LLM_BASE_URL || 'http://localhost:11434/v1' })
19
+ : '';
20
+ const defaultModel = provider === 'openai_compatible' ? 'gpt-4o' : 'claude-sonnet-4-5';
21
+ const model = await promptText('LLM model', { defaultValue: existing.LLM_MODEL || defaultModel });
22
+ return { LLM_PROVIDER: provider, LLM_API_KEY: apiKey, LLM_BASE_URL: baseUrl, LLM_MODEL: model };
23
+ }
24
+ /**
25
+ * M17 first-run question, right after askLlmConfig — default Yes only if a real key was just
26
+ * provided in this same wizard run (defaulting to a setting that immediately fails for lack of
27
+ * a key would be worse than defaulting to No).
28
+ */
29
+ async function askAutoGenerateDocs(llm) {
30
+ const enabled = await promptConfirm('Auto-generate PRD/TDD documents with an LLM after each sync?', Boolean(llm.LLM_API_KEY));
31
+ return enabled ? '1' : '0';
32
+ }
33
+ /**
34
+ * Bundled installs have no `apps/wiki-api/src/auth.ts` on disk for `@better-auth/cli` to read
35
+ * (it was inlined by esbuild, M21) — wiki-api's own compiled entry gets a `--migrate-only` mode
36
+ * instead (M22) that runs the same migration programmatically. Dev mode is untouched: it still
37
+ * shells out to the CLI against the real source file.
38
+ */
39
+ function runBetterAuthMigrate(install, env) {
40
+ const spawnEnv = { ...process.env, ...env };
41
+ if (install.mode === 'bundled') {
42
+ execFileSync(process.execPath, [bundledServiceEntry('wiki-api'), '--migrate-only'], { env: spawnEnv, stdio: 'inherit' });
43
+ return;
44
+ }
45
+ execFileSync('npx', ['@better-auth/cli', 'migrate', '-y'], { cwd: join(install.repoRoot, 'apps/wiki-api'), env: spawnEnv, stdio: 'inherit' });
46
+ }
47
+ async function provisionFirstUser(wikiApiBase, origin) {
48
+ const email = await promptText('Your email (for the first NoStale account)');
49
+ const password = await promptPassword('Choose a password');
50
+ printStep('Provisioning your account');
51
+ // BetterAuth rejects sign-up without an Origin header matching trustedOrigins, even for
52
+ // same-machine, non-browser callers like this CLI.
53
+ const response = await fetch(`${wikiApiBase}/api/auth/sign-up/email`, {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json', origin },
56
+ body: JSON.stringify({ email, password, name: email.split('@')[0] }),
57
+ });
58
+ if (response.ok) {
59
+ printInfo(`Account ${email} created — sign in at the wiki-web URL below.`);
60
+ return;
61
+ }
62
+ const body = await response.json().catch(() => ({}));
63
+ printInfo(`Could not auto-provision (${response.status}): ${body.message ?? 'unknown error'} — sign up manually instead.`);
64
+ }
65
+ /**
66
+ * M18 — lets personal/server mode skip installing wiki-web (e.g. MCP-only or CLI-only usage).
67
+ * Persisted as WIKI_WEB_ENABLED ('1'/'0'), same boolean-ish convention as AUTO_GENERATE_DOCS.
68
+ */
69
+ async function askInstallWebUi(existing) {
70
+ const install = await promptConfirm('Install Web UI?', existing.WIKI_WEB_ENABLED !== '0');
71
+ return install ? '1' : '0';
72
+ }
73
+ async function runPersonalSetup(install, envPath, existing) {
74
+ const installWebUi = await askInstallWebUi(existing);
75
+ const llm = await askLlmConfig(existing);
76
+ const autoGenerateDocs = await askAutoGenerateDocs(llm);
77
+ const useTurso = await promptConfirm('Use Turso Cloud instead of a local database file?', Boolean(existing.LIBSQL_AUTH_TOKEN));
78
+ let libsqlUrl = `file:${DEFAULT_DB_PATH}`;
79
+ let libsqlAuthToken = '';
80
+ if (useTurso) {
81
+ libsqlUrl = await promptText('Turso database URL', { defaultValue: existing.LIBSQL_URL });
82
+ libsqlAuthToken = await promptPassword('Turso auth token', { defaultValue: existing.LIBSQL_AUTH_TOKEN });
83
+ }
84
+ printStep('Picking free ports');
85
+ const taken = new Set();
86
+ const wikiWebPort = await pickPort(Number(existing.WIKI_WEB_PORT) || 3000, taken);
87
+ taken.add(wikiWebPort);
88
+ const wikiApiPort = await pickPort(Number(existing.WIKI_API_PORT) || 3001, taken);
89
+ taken.add(wikiApiPort);
90
+ const wikiWorkerPort = await pickPort(Number(existing.WIKI_WORKER_PORT) || 3002, taken);
91
+ taken.add(wikiWorkerPort);
92
+ const mcpServerPort = await pickPort(Number(existing.MCP_SERVER_PORT) || 3100, taken);
93
+ taken.add(mcpServerPort);
94
+ printInfo(`wiki-web=${wikiWebPort} wiki-api=${wikiApiPort} wiki-worker=${wikiWorkerPort} mcp-server=${mcpServerPort}`);
95
+ const wikiApiBase = `http://localhost:${wikiApiPort}`;
96
+ const vars = {
97
+ NOSTALE_MODE: 'personal',
98
+ LIBSQL_URL: libsqlUrl,
99
+ LIBSQL_AUTH_TOKEN: libsqlAuthToken,
100
+ SETTINGS_ENCRYPTION_KEY: existing.SETTINGS_ENCRYPTION_KEY || generateEncryptionKey(),
101
+ // Shared secret wiki-worker requires on every request (docs/13 finding #2) — both processes
102
+ // read the same .env, so generating it once here wires it to wiki-api and wiki-worker alike.
103
+ WIKI_WORKER_SHARED_SECRET: existing.WIKI_WORKER_SHARED_SECRET || generateEncryptionKey(),
104
+ // BetterAuth session-signing secret — without it, auth.ts falls back to a publicly-known
105
+ // default (SECURITY-REVIEW-2026-07-10.md F-01).
106
+ BETTER_AUTH_SECRET: existing.BETTER_AUTH_SECRET || generateEncryptionKey(),
107
+ ...llm,
108
+ WIKI_API_PORT: String(wikiApiPort),
109
+ WIKI_WORKER_PORT: String(wikiWorkerPort),
110
+ MCP_SERVER_PORT: String(mcpServerPort),
111
+ WIKI_WEB_PORT: String(wikiWebPort),
112
+ BETTER_AUTH_URL: wikiApiBase,
113
+ WIKI_WEB_ORIGIN: `http://localhost:${wikiWebPort}`,
114
+ WIKI_API_BASE: wikiApiBase,
115
+ WIKI_WORKER_URL: `http://localhost:${wikiWorkerPort}`,
116
+ WIKI_WORKER_WORKDIR: existing.WIKI_WORKER_WORKDIR || DEFAULT_WORK_DIR,
117
+ ASK_WIKI_RATE_LIMIT: existing.ASK_WIKI_RATE_LIMIT || '5',
118
+ AUTO_GENERATE_DOCS: autoGenerateDocs,
119
+ WIKI_WEB_ENABLED: installWebUi,
120
+ };
121
+ mkdirSync(DATA_DIR, { recursive: true });
122
+ writeEnvFile(envPath, vars);
123
+ printStep('Wrote .env for personal mode');
124
+ printStep('Starting services (no Docker) — wiki-api first, others once its migration finishes');
125
+ const ports = {
126
+ 'wiki-api': wikiApiPort,
127
+ 'wiki-worker': wikiWorkerPort,
128
+ 'mcp-server': mcpServerPort,
129
+ 'wiki-web': wikiWebPort,
130
+ };
131
+ const started = await startAllPersonal(install, vars, ports);
132
+ for (const s of started)
133
+ printInfo(`${s.name} started on :${ports[s.name]} (pid ${s.pid})`);
134
+ const healthy = await waitForHttp(wikiApiBase);
135
+ if (!healthy) {
136
+ printInfo('wiki-api did not respond in time — check `nostale status` and ~/.nostale/logs/wiki-api.log');
137
+ return;
138
+ }
139
+ printStep('Provisioning BetterAuth schema');
140
+ runBetterAuthMigrate(install, vars);
141
+ if (await promptConfirm('Create the first NoStale account now?')) {
142
+ await provisionFirstUser(wikiApiBase, vars.WIKI_WEB_ORIGIN);
143
+ }
144
+ printStep('Done');
145
+ if (installWebUi === '1')
146
+ printInfo(`wiki-web: http://localhost:${wikiWebPort}`);
147
+ else
148
+ printInfo('wiki-web: disabled — manage everything via `nostale config`.');
149
+ printInfo(`wiki-api: ${wikiApiBase}`);
150
+ printInfo(`mcp-server: http://localhost:${mcpServerPort}`);
151
+ printInfo('Check status anytime: `nostale status` — stop with `nostale stop` — reconfigure with `nostale config`.');
152
+ }
153
+ /**
154
+ * Bundled installs have no repo checkout to hold a docker-compose.yml, so `nostale setup` (server
155
+ * mode) writes the CLI's own image-based variant to `~/.nostale/docker-compose.yml` (M23) — every
156
+ * service pulls `ghcr.io/.../nostale-<service>:<version>` instead of building from source, with
157
+ * `<version>` substituted from this install's own package.json (kept in lockstep with the GHCR
158
+ * tags a release publishes, see docs/14-npm-standalone-distribution.md's release contract).
159
+ */
160
+ function writeServerComposeFile(targetDir) {
161
+ const template = readFileSync(join(cliPackageRoot(), 'assets/docker-compose.server.yml'), 'utf-8');
162
+ const rendered = template.replaceAll('${NOSTALE_VERSION}', cliVersion());
163
+ const target = join(targetDir, 'docker-compose.yml');
164
+ writeFileSync(target, rendered);
165
+ return target;
166
+ }
167
+ async function runServerSetup(install, envPath, existing) {
168
+ const installWebUi = await askInstallWebUi(existing);
169
+ const llm = await askLlmConfig(existing);
170
+ const autoGenerateDocs = await askAutoGenerateDocs(llm);
171
+ // Both the dev-mode and bundled-mode compose files hardcode LIBSQL_URL to the sqld container,
172
+ // so Turso Cloud isn't an option here (it is in personal mode).
173
+ const wikiApiPublicUrl = await promptText('Public URL for wiki-api (used by BetterAuth)', {
174
+ defaultValue: existing.WIKI_API_PUBLIC_URL || 'http://localhost:3001',
175
+ });
176
+ const vars = {
177
+ NOSTALE_MODE: 'server',
178
+ SETTINGS_ENCRYPTION_KEY: existing.SETTINGS_ENCRYPTION_KEY || generateEncryptionKey(),
179
+ WIKI_WORKER_SHARED_SECRET: existing.WIKI_WORKER_SHARED_SECRET || generateEncryptionKey(),
180
+ BETTER_AUTH_SECRET: existing.BETTER_AUTH_SECRET || generateEncryptionKey(),
181
+ // docker-compose.yml now requires this (SECURITY-REVIEW-2026-07-10.md F-06) — an empty
182
+ // token left sqld accepting unauthenticated connections on the internal network.
183
+ SQLD_AUTH_TOKEN: existing.SQLD_AUTH_TOKEN || generateEncryptionKey(),
184
+ LLM_PROVIDER: llm.LLM_PROVIDER,
185
+ LLM_API_KEY: llm.LLM_API_KEY,
186
+ LLM_BASE_URL: llm.LLM_BASE_URL,
187
+ WIKI_API_PUBLIC_URL: wikiApiPublicUrl,
188
+ AUTO_GENERATE_DOCS: autoGenerateDocs,
189
+ WIKI_WEB_ENABLED: installWebUi,
190
+ // Compose auto-loads .env from its cwd, so COMPOSE_PROFILES here activates the `web` profile
191
+ // for every future `docker compose` invocation (ps/down/up), not just this setup run — that's
192
+ // what makes `nostale status`/`nostale stop`'s unflagged `docker compose ps`/`down` calls
193
+ // correctly include/exclude wiki-web without those files needing to know about profiles at all.
194
+ COMPOSE_PROFILES: installWebUi === '1' ? 'web' : '',
195
+ };
196
+ const dir = installDir(install);
197
+ // Personal mode's mkdirSync(DATA_DIR) happens to create ~/.nostale too (recursive: true), but
198
+ // server mode has no equivalent call — harmless in dev mode (repoRoot already exists from the
199
+ // clone) but a fresh bundled install has no ~/.nostale yet at all (M23).
200
+ mkdirSync(dir, { recursive: true });
201
+ writeEnvFile(envPath, vars);
202
+ printStep('Wrote .env for server mode');
203
+ // Dev mode reuses the monorepo's own docker-compose.yml (build: context: ., unchanged) — only
204
+ // a bundled install needs one written out, since there's no checkout to hold it (M23).
205
+ if (install.mode === 'bundled') {
206
+ writeServerComposeFile(dir);
207
+ printStep(`Wrote docker-compose.yml (image-based, v${cliVersion()}) to ${dir}`);
208
+ }
209
+ printStep('Starting docker compose stack');
210
+ const composeArgs = installWebUi === '1' ? ['compose', '--profile', 'web', 'up', '-d'] : ['compose', 'up', '-d'];
211
+ try {
212
+ execFileSync('docker', composeArgs, { cwd: dir, stdio: 'inherit' });
213
+ }
214
+ catch (err) {
215
+ printInfo(`Failed to run \`docker compose up -d\`: ${err.message}`);
216
+ printInfo('Install Docker Engine + Compose plugin, then re-run `nostale setup`.');
217
+ return;
218
+ }
219
+ printStep('Waiting for wiki-api container to come up');
220
+ const healthy = await waitForHttp(wikiApiPublicUrl);
221
+ if (!healthy) {
222
+ printInfo('wiki-api did not respond in time — check `docker compose logs wiki-api`.');
223
+ return;
224
+ }
225
+ printStep('Provisioning BetterAuth schema');
226
+ for (let attempt = 1; attempt <= 5; attempt++) {
227
+ try {
228
+ execFileSync('docker', ['compose', 'exec', '-T', 'wiki-api', 'npx', '@better-auth/cli', 'migrate', '-y'], {
229
+ cwd: dir,
230
+ stdio: 'inherit',
231
+ });
232
+ break;
233
+ }
234
+ catch (err) {
235
+ if (attempt === 5)
236
+ printInfo(`BetterAuth migration failed after retries: ${err.message}`);
237
+ else
238
+ await new Promise((resolve) => setTimeout(resolve, 2000));
239
+ }
240
+ }
241
+ printStep('Done');
242
+ if (installWebUi === '1')
243
+ printInfo('wiki-web: http://localhost:3000 — open it and sign up for the first account.');
244
+ else
245
+ printInfo('wiki-web: disabled — manage everything via `nostale config`.');
246
+ printInfo(`wiki-api: ${wikiApiPublicUrl}`);
247
+ printInfo('Check the stack: `docker compose ps` — reconfigure with `nostale config`.');
248
+ }
249
+ export async function runWizard(install) {
250
+ if (!process.stdin.isTTY) {
251
+ console.error('`nostale setup`/`nostale config` need an interactive terminal (Ink TUI) — run them directly in a shell, not piped or in CI.');
252
+ process.exitCode = 1;
253
+ return;
254
+ }
255
+ const envPath = envFilePath(installDir(install));
256
+ const existing = readEnvFile(envPath);
257
+ printStep('NoStale setup');
258
+ const defaultMode = existing.NOSTALE_MODE || 'personal';
259
+ const mode = await promptSelect('Mode', [
260
+ { label: `Personal (no Docker, local file DB)${defaultMode === 'personal' ? ' — current' : ''}`, value: 'personal' },
261
+ { label: `Server (Docker Compose, full sqld stack)${defaultMode === 'server' ? ' — current' : ''}`, value: 'server' },
262
+ ]);
263
+ if (mode === 'personal')
264
+ await runPersonalSetup(install, envPath, existing);
265
+ else
266
+ await runServerSetup(install, envPath, existing);
267
+ }
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { render, Box, Text } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import SelectInput from 'ink-select-input';
6
+ function PromptText({ message, defaultValue, mask, onSubmit, }) {
7
+ const [value, setValue] = useState('');
8
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "? " }), _jsxs(Text, { bold: true, children: [message, " "] }), _jsx(TextInput, { value: value, onChange: setValue, mask: mask ? '*' : undefined, onSubmit: onSubmit })] }));
9
+ }
10
+ /** Renders one Ink prompt, resolves on submit, unmounts — one small render per question. */
11
+ export async function promptText(message, opts = {}) {
12
+ const label = opts.defaultValue ? `${message} (${opts.defaultValue})` : message;
13
+ return new Promise((resolve) => {
14
+ const { unmount, waitUntilExit } = render(_jsx(PromptText, { message: label, mask: opts.mask, onSubmit: (value) => {
15
+ unmount();
16
+ resolve(value.trim() === '' ? (opts.defaultValue ?? '') : value.trim());
17
+ } }));
18
+ void waitUntilExit();
19
+ });
20
+ }
21
+ export function promptPassword(message, opts = {}) {
22
+ return promptText(message, { ...opts, mask: true });
23
+ }
24
+ function PromptSelect({ message, choices, onSubmit, }) {
25
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: ["? ", message] }), _jsx(SelectInput, { items: choices, onSelect: (item) => onSubmit(item.value) })] }));
26
+ }
27
+ export async function promptSelect(message, choices) {
28
+ return new Promise((resolve) => {
29
+ const { unmount, waitUntilExit } = render(_jsx(PromptSelect, { message: message, choices: choices, onSubmit: (value) => {
30
+ unmount();
31
+ resolve(value);
32
+ } }));
33
+ void waitUntilExit();
34
+ });
35
+ }
36
+ export async function promptConfirm(message, defaultValue = true) {
37
+ const choices = defaultValue
38
+ ? [
39
+ { label: 'Yes', value: 'yes' },
40
+ { label: 'No', value: 'no' },
41
+ ]
42
+ : [
43
+ { label: 'No', value: 'no' },
44
+ { label: 'Yes', value: 'yes' },
45
+ ];
46
+ const answer = await promptSelect(message, choices);
47
+ return answer === 'yes';
48
+ }
49
+ export function printStep(message) {
50
+ console.log(`\n> ${message}`);
51
+ }
52
+ export function printInfo(message) {
53
+ console.log(` ${message}`);
54
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@nostale/cli",
3
+ "version": "0.1.0",
4
+ "description": "NoStale installer CLI/TUI — setup wizard, local process supervisor, doctor",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "nostale": "./dist/index.js"
9
+ },
10
+ "homepage": "https://github.com/fetot/nostale/tree/main/apps/cli#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/fetot/nostale.git",
14
+ "directory": "apps/cli"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/fetot/nostale/issues"
18
+ },
19
+ "keywords": [
20
+ "wiki",
21
+ "documentation",
22
+ "prd",
23
+ "tdd",
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "ast",
27
+ "codebase-extraction",
28
+ "cli"
29
+ ],
30
+ "author": "Ferdy Hasan Isma",
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "os": [
35
+ "darwin",
36
+ "linux"
37
+ ],
38
+ "files": [
39
+ "dist",
40
+ "services",
41
+ "assets",
42
+ "LICENSE"
43
+ ],
44
+ "scripts": {
45
+ "build": "tsc && tsx scripts/bundle-services.ts",
46
+ "start": "node dist/index.js",
47
+ "dev": "tsx src/index.ts",
48
+ "test": "tsx --test"
49
+ },
50
+ "license": "MIT",
51
+ "dependencies": {
52
+ "@libsql/client": "^0.17.4",
53
+ "better-auth": "^1.6.23",
54
+ "commander": "^15.0.0",
55
+ "ink": "^7.1.0",
56
+ "ink-select-input": "^6.2.0",
57
+ "ink-text-input": "^6.0.0",
58
+ "officeparser": "^7.2.3",
59
+ "pdf-parse": "^2.4.5",
60
+ "react": "^19.2.7"
61
+ },
62
+ "devDependencies": {
63
+ "@types/node": "^22.10.1",
64
+ "@types/react": "^19.2.0",
65
+ "esbuild": "^0.28.1",
66
+ "tsx": "^4.19.2",
67
+ "typescript": "^7.0.2"
68
+ }
69
+ }