superdev-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 (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,783 @@
1
+ // The local HTTP service. Brief section 14.2, routes fixed by MODULE_CONTRACT.md.
2
+ //
3
+ // One Node process per project, bound to the loopback interface, serving the
4
+ // compiled control center and the read and mutation API that backs it. No
5
+ // external network is involved at any point.
6
+ //
7
+ // Change detection polls `max(sequence)` from activity_events on a fresh
8
+ // readonly connection about once a second. Two cheaper approaches do not work on
9
+ // this engine and were measured, not assumed: `PRAGMA data_version` is absent,
10
+ // and watching the database file misses commits because they land in the
11
+ // write-ahead log first. A poll on a fresh connection is the only signal that is
12
+ // actually correct, and it costs about 0.3 ms.
13
+
14
+ import { createServer } from "node:http";
15
+ import { randomUUID } from "node:crypto";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { query } from "../db/store.mjs";
21
+ import * as readModel from "./read-model.mjs";
22
+ import { applyMutation, ALLOWED_ACTIONS } from "./mutations.mjs";
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const BUNDLE = join(HERE, "assets", "control-center.html");
26
+ const DATA_PLACEHOLDER = "/*__SUPERDEV_DATA__*/null";
27
+
28
+ export const DEFAULTS = {
29
+ host: "127.0.0.1",
30
+ port: 4317,
31
+ /** How many ports above the requested one to try before giving up. */
32
+ portScan: 20,
33
+ /** Largest mutation body accepted, in bytes. */
34
+ bodyLimit: 1024 * 1024,
35
+ /** How often the change poller asks the database for its latest sequence. */
36
+ pollMs: 1000,
37
+ heartbeatMs: 15_000,
38
+ /** Change events kept for a reconnecting client. Older than this is a resync. */
39
+ replayBuffer: 256,
40
+ /** No request and no events client for this long shuts the service down. */
41
+ idleTimeoutMs: 30 * 60 * 1000,
42
+ idleCheckMs: 30_000,
43
+ };
44
+
45
+ /**
46
+ * Consecutive change-poll failures before the stream admits it has gone blind.
47
+ *
48
+ * One failure is another process mid-commit and means nothing. Five in a row,
49
+ * at one second apart, is a fault that is not clearing, and by then a quiet
50
+ * stream is indistinguishable from a quiet project.
51
+ */
52
+ const DEGRADE_AFTER = 5;
53
+
54
+ const VERSION = (() => {
55
+ try {
56
+ return JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")).version ?? "0.0.0";
57
+ } catch {
58
+ return "0.0.0";
59
+ }
60
+ })();
61
+
62
+ // ------------------------------------------------------------------- helpers
63
+
64
+ // `<` and `>` would let a value close the script element early, and U+2028 and
65
+ // U+2029 are line terminators in JavaScript but ordinary characters in JSON.
66
+ const SCRIPT_UNSAFE = {
67
+ "<": "\\u003c",
68
+ ">": "\\u003e",
69
+ "\u2028": "\\u2028",
70
+ "\u2029": "\\u2029",
71
+ };
72
+
73
+ /** Make a JSON string safe to place inside a script element. */
74
+ export const escapeForScript = (jsonText) =>
75
+ jsonText.replace(/[<>\u2028\u2029]/g, (c) => SCRIPT_UNSAFE[c]);
76
+
77
+ const NO_STORE = { "cache-control": "no-store" };
78
+
79
+ function sendJson(res, status, body) {
80
+ const text = JSON.stringify(body);
81
+ res.writeHead(status, {
82
+ ...NO_STORE,
83
+ "content-type": "application/json; charset=utf-8",
84
+ "content-length": Buffer.byteLength(text),
85
+ });
86
+ res.end(text);
87
+ }
88
+
89
+ function sendHtml(res, status, html, headOnly = false) {
90
+ res.writeHead(status, {
91
+ ...NO_STORE,
92
+ "content-type": "text/html; charset=utf-8",
93
+ "content-length": Buffer.byteLength(html),
94
+ });
95
+ // A HEAD reply carries the headers and no body, including the length the
96
+ // body would have had.
97
+ res.end(headOnly ? undefined : html);
98
+ }
99
+
100
+ /**
101
+ * Turn any thrown value into the error envelope the interface knows how to
102
+ * render: a stable code and a sentence that says what to do next.
103
+ */
104
+ function errorBody(err) {
105
+ const code = typeof err?.code === "string" && err.code.startsWith("E_") ? err.code : "E_INTERNAL";
106
+ // An error with no stable code is a defect, not a refusal. The response says
107
+ // so in one sentence; the stack belongs in the log, which is what the log is
108
+ // for and the only place anyone can find it afterwards.
109
+ if (code === "E_INTERNAL") process.stderr.write(`${err?.stack ?? err}\n`);
110
+ const status = {
111
+ E_NOT_FOUND: 404,
112
+ E_UNKNOWN_ACTION: 400,
113
+ E_INVALID_PAYLOAD: 400,
114
+ E_INVALID_TRANSITION: 409,
115
+ E_VERSION_CONFLICT: 409,
116
+ E_ALREADY_CLAIMED: 409,
117
+ E_NOT_CLAIMED: 409,
118
+ E_OPEN_SUBTASKS: 409,
119
+ E_EVIDENCE_MISSING: 409,
120
+ E_EVIDENCE_FAILING: 409,
121
+ E_ACCEPTANCE_UNMET: 409,
122
+ E_TASK_WITHOUT_CONTRACT: 409,
123
+ E_ENABLING_WITHOUT_TARGET: 409,
124
+ E_APPEND_ONLY: 409,
125
+ E_REASON_REQUIRED: 400,
126
+ E_UNKNOWN_TARGET: 400,
127
+ E_STATUS_VIA_TRANSITION: 400,
128
+ E_STYLE_EM_DASH: 400,
129
+ E_STYLE_EMOJI: 400,
130
+ E_SECRET_SHAPED: 400,
131
+ E_ABSOLUTE_HOME_PATH: 400,
132
+ E_MODEL_REASONING_FIELD: 400,
133
+ E_DB_LOCKED: 503,
134
+ E_INTERNAL: 500,
135
+ // A module error carrying a stable code is a refusal the caller can act on,
136
+ // never a crash, so an unlisted one is a conflict rather than a 500.
137
+ }[code] ?? 409;
138
+ return {
139
+ status,
140
+ body: {
141
+ code,
142
+ message:
143
+ code === "E_INTERNAL"
144
+ ? "The service hit an unexpected problem handling that request. Check the service log, then try again."
145
+ : String(err.message ?? code),
146
+ ...(err?.detail !== undefined ? { details: err.detail } : null),
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * A request is same origin when it declares no origin (a direct fetch from the
153
+ * page or a command line client) or declares one of the addresses this service
154
+ * is actually reachable on. Anything else is another site trying to drive the
155
+ * local service through the browser.
156
+ */
157
+ const LOOPBACK = ["127.0.0.1", "localhost", "[::1]", "::1"];
158
+
159
+ function sameOrigin(req, port) {
160
+ const origin = req.headers.origin;
161
+ if (!origin) return true;
162
+ let url;
163
+ try {
164
+ url = new URL(origin);
165
+ } catch {
166
+ return false;
167
+ }
168
+ return LOOPBACK.includes(url.hostname) && url.port === String(port);
169
+ }
170
+
171
+ /**
172
+ * Whether the request addressed this service by a loopback name.
173
+ *
174
+ * This is the DNS rebinding defence, and it is the reason the origin check is
175
+ * not enough on its own. A page on another site can point a hostname it owns at
176
+ * 127.0.0.1, and once it resolves there the browser treats the service as that
177
+ * site's own origin: it will send no `Origin` header at all on a plain read, so
178
+ * `sameOrigin` returns true and the project database answers a stranger. What
179
+ * the attacker cannot forge is `Host`, which still carries the name they used.
180
+ * A service bound to loopback is only ever legitimately addressed as loopback.
181
+ */
182
+ function addressedAsLoopback(req) {
183
+ const header = req.headers.host;
184
+ // No Host at all is HTTP/1.0 or a handmade request, never a browser.
185
+ if (!header) return true;
186
+ const hostname = header.startsWith("[")
187
+ ? header.slice(0, header.indexOf("]") + 1)
188
+ : header.split(":")[0];
189
+ return LOOPBACK.includes(hostname);
190
+ }
191
+
192
+ function readBody(req, limit) {
193
+ return new Promise((resolve, reject) => {
194
+ let size = 0;
195
+ const chunks = [];
196
+ req.on("data", (chunk) => {
197
+ size += chunk.length;
198
+ if (size > limit) {
199
+ // Stop reading rather than tearing the socket down, so the refusal has
200
+ // a chance to reach the caller instead of arriving as a reset.
201
+ req.pause();
202
+ const err = new Error(
203
+ `The request body is larger than ${Math.round(limit / 1024)} KB, which this service refuses. Send a smaller change.`,
204
+ );
205
+ err.code = "E_PAYLOAD_TOO_LARGE";
206
+ reject(err);
207
+ return;
208
+ }
209
+ chunks.push(chunk);
210
+ });
211
+ req.on("error", reject);
212
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
213
+ });
214
+ }
215
+
216
+ const FALLBACK_PAGE = `<!doctype html>
217
+ <html lang="en">
218
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
219
+ <title>Superdev</title>
220
+ <style>
221
+ :root { color-scheme: light dark; }
222
+ body { font: 16px/1.6 system-ui, sans-serif; margin: 0 auto; max-width: 42rem; padding: 3rem 1.5rem; }
223
+ code { background: rgba(127,127,127,.18); border-radius: .25rem; padding: .1em .35em; }
224
+ h1 { font-size: 1.4rem; }
225
+ </style>
226
+ </head>
227
+ <body>
228
+ <h1>The Superdev control center has not been built yet</h1>
229
+ <p>The service is running and the API is answering. The compiled interface is not
230
+ in this installation, so there is nothing to show here yet.</p>
231
+ <p>Build it from the repository root:</p>
232
+ <p><code>npm run ui:build</code></p>
233
+ <p>Then reload this page. The API is available meanwhile at
234
+ <code>/api/overview</code>, and <code>/health</code> reports which project this
235
+ service is pointed at.</p>
236
+ </body>
237
+ </html>
238
+ `;
239
+
240
+ // ---------------------------------------------------------------- the service
241
+
242
+ /**
243
+ * Start the service for one project.
244
+ *
245
+ * Returns the port it actually bound (which may not be the one requested) and a
246
+ * `close` that shuts the listener, the timers and every open events client down.
247
+ */
248
+ export async function serve(root, options = {}) {
249
+ const settings = { ...DEFAULTS, ...options };
250
+ const instanceToken = options.instanceToken ?? randomUUID();
251
+
252
+ const clients = new Set();
253
+ /** Recent change events, newest last, for a client that reconnects. */
254
+ const replay = [];
255
+ let lastSequence = 0;
256
+ let lastRequestAt = Date.now();
257
+ let closing = false;
258
+ /** Mutations running right now, so the change poller can stand out of the way. */
259
+ let writesInFlight = 0;
260
+ /** Consecutive change-poll failures, and whether clients have been told. */
261
+ let pollFailures = 0;
262
+ let degraded = false;
263
+
264
+ const server = createServer((req, res) => {
265
+ lastRequestAt = Date.now();
266
+ handle(req, res).catch((err) => {
267
+ if (res.headersSent) {
268
+ res.end();
269
+ return;
270
+ }
271
+ const { status, body } = errorBody(err);
272
+ sendJson(res, status, body);
273
+ });
274
+ });
275
+
276
+ async function handle(req, res) {
277
+ const url = new URL(req.url, `http://${req.headers.host ?? "127.0.0.1"}`);
278
+ const path = url.pathname.replace(/\/+$/, "") || "/";
279
+
280
+ // Ahead of every route, including the reads and the stream. The write path
281
+ // guarded itself and the read path did not, which made the whole project
282
+ // database readable by any page that could rebind a name to this port. A
283
+ // read surface that leaks the project is not a smaller problem than a write
284
+ // surface that changes it.
285
+ if (settings.host === "127.0.0.1" && !addressedAsLoopback(req)) {
286
+ return sendJson(res, 403, {
287
+ code: "E_FOREIGN_HOST",
288
+ message:
289
+ "That request reached this service under a name that is not loopback. The Superdev service answers only to 127.0.0.1 and localhost on this machine.",
290
+ });
291
+ }
292
+
293
+ if (path === "/health") {
294
+ return sendJson(res, 200, { ok: true, projectRoot: root, instanceToken, version: VERSION });
295
+ }
296
+
297
+ // Reads and the stream carry project content, so they answer the same
298
+ // question the write path asks: did this come from our own page.
299
+ if (path.startsWith("/api/") && !sameOrigin(req, boundPort)) {
300
+ return sendJson(res, 403, {
301
+ code: "E_CROSS_ORIGIN",
302
+ message:
303
+ "That request came from another origin. The Superdev service only answers its own page on this machine.",
304
+ });
305
+ }
306
+
307
+ if (path === "/api/events") {
308
+ if (req.method !== "GET") return sendJson(res, 405, methodNotAllowed("GET"));
309
+ return openEventStream(req, res, url);
310
+ }
311
+
312
+ if (path === "/api/mutations") {
313
+ if (req.method !== "POST") return sendJson(res, 405, methodNotAllowed("POST"));
314
+ return handleMutation(req, res);
315
+ }
316
+
317
+ if (path.startsWith("/api/")) {
318
+ if (req.method !== "GET") return sendJson(res, 405, methodNotAllowed("GET"));
319
+ return handleRead(req, res, url, path);
320
+ }
321
+
322
+ // HEAD is how a person or a script checks the service is serving the
323
+ // interface at all. Answering 404 to it says the page does not exist.
324
+ if (path === "/" && (req.method === "GET" || req.method === "HEAD")) {
325
+ return sendPage(res, req.method === "HEAD");
326
+ }
327
+
328
+ return sendJson(res, 404, {
329
+ code: "E_NOT_FOUND",
330
+ message: `${req.method} ${path} is not part of this service. The routes are /health, /api/events, /api/mutations and the /api read routes.`,
331
+ });
332
+ }
333
+
334
+ const methodNotAllowed = (allowed) => ({
335
+ code: "E_METHOD_NOT_ALLOWED",
336
+ message: `That route only answers ${allowed}.`,
337
+ });
338
+
339
+ // ------------------------------------------------------------------- reads
340
+
341
+ async function handleRead(req, res, url, path) {
342
+ const builder = readModel.routes[path];
343
+ if (builder) return sendJson(res, 200, await builder(root));
344
+
345
+ const featureMatch = /^\/api\/features\/([^/]+)$/.exec(path);
346
+ if (featureMatch) {
347
+ return sendJson(res, 200, await readModel.feature(root, decodeURIComponent(featureMatch[1])));
348
+ }
349
+
350
+ if (path === "/api/activity") {
351
+ const numeric = (name) => {
352
+ const raw = url.searchParams.get(name);
353
+ if (raw === null) return undefined;
354
+ const value = Number(raw);
355
+ return Number.isFinite(value) ? value : undefined;
356
+ };
357
+ return sendJson(
358
+ res,
359
+ 200,
360
+ await readModel.activity(root, {
361
+ before: numeric("before"),
362
+ limit: numeric("limit"),
363
+ search: url.searchParams.get("search") ?? undefined,
364
+ }),
365
+ );
366
+ }
367
+
368
+ return sendJson(res, 404, {
369
+ code: "E_NOT_FOUND",
370
+ message: `${path} is not one of this service's read routes.`,
371
+ });
372
+ }
373
+
374
+ // --------------------------------------------------------------- mutations
375
+
376
+ async function handleMutation(req, res) {
377
+ if (!sameOrigin(req, boundPort)) {
378
+ return sendJson(res, 403, {
379
+ code: "E_CROSS_ORIGIN",
380
+ message:
381
+ "That request came from another origin. The Superdev service only accepts changes from its own page on this machine.",
382
+ });
383
+ }
384
+ const type = String(req.headers["content-type"] ?? "");
385
+ if (!type.startsWith("application/json")) {
386
+ return sendJson(res, 415, {
387
+ code: "E_UNSUPPORTED_MEDIA_TYPE",
388
+ message: "A mutation has to be sent as application/json.",
389
+ });
390
+ }
391
+
392
+ let raw;
393
+ try {
394
+ raw = await readBody(req, settings.bodyLimit);
395
+ } catch (err) {
396
+ const oversized = err.code === "E_PAYLOAD_TOO_LARGE";
397
+ if (oversized) res.setHeader("connection", "close");
398
+ sendJson(res, oversized ? 413 : 400, {
399
+ code: err.code ?? "E_BAD_REQUEST",
400
+ message: String(err.message),
401
+ });
402
+ if (oversized) req.destroy();
403
+ return;
404
+ }
405
+
406
+ let parsed;
407
+ try {
408
+ parsed = JSON.parse(raw || "{}");
409
+ } catch {
410
+ return sendJson(res, 400, {
411
+ code: "E_INVALID_PAYLOAD",
412
+ message: "The request body was not valid JSON.",
413
+ });
414
+ }
415
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
416
+ return sendJson(res, 400, {
417
+ code: "E_INVALID_PAYLOAD",
418
+ message: `A mutation is an object with an action and a payload. Accepted actions: ${ALLOWED_ACTIONS.join(", ")}.`,
419
+ });
420
+ }
421
+
422
+ let data;
423
+ try {
424
+ data = await runMutation(parsed.action, parsed.payload);
425
+ } catch (err) {
426
+ const { status, body } = errorBody(err);
427
+ return sendJson(res, status, body);
428
+ }
429
+
430
+ const revision = (await query(root, (db) => db.value("SELECT max(sequence) FROM activity_events"))) ?? 0;
431
+ return sendJson(res, 200, {
432
+ data,
433
+ meta: {
434
+ revision,
435
+ lastEventSequence: revision,
436
+ generatedAt: new Date().toISOString(),
437
+ stale: false,
438
+ },
439
+ });
440
+ }
441
+
442
+ /**
443
+ * The engine refuses to open a write connection while this same process still
444
+ * holds a readonly one, and it reports that as "Resource is read-only" rather
445
+ * than as a lock, so the retry in connect.mjs does not cover it. This service
446
+ * is the one place that reads and writes from a single process, so it owns the
447
+ * problem: the poller stands down while a mutation runs, and a mutation that
448
+ * still lands on top of an in-flight read backs off and tries again. Retrying
449
+ * is safe because the refusal arrives on BEGIN IMMEDIATE, before any statement.
450
+ */
451
+ async function runMutation(action, payload) {
452
+ writesInFlight += 1;
453
+ try {
454
+ for (let attempt = 0; ; attempt++) {
455
+ try {
456
+ return await applyMutation(root, action, payload);
457
+ } catch (err) {
458
+ if (attempt >= 5 || !/read[- ]?only/i.test(String(err?.message ?? ""))) throw err;
459
+ await new Promise((r) => setTimeout(r, 12 * (attempt + 1)));
460
+ }
461
+ }
462
+ } finally {
463
+ writesInFlight -= 1;
464
+ }
465
+ }
466
+
467
+ // ------------------------------------------------------------------ events
468
+
469
+ function write(client, text) {
470
+ if (client.res.writableEnded) return;
471
+ client.res.write(text);
472
+ }
473
+
474
+ function openEventStream(req, res, url) {
475
+ res.writeHead(200, {
476
+ ...NO_STORE,
477
+ "content-type": "text/event-stream; charset=utf-8",
478
+ connection: "keep-alive",
479
+ // Proxies that buffer would defeat the whole point of the stream.
480
+ "x-accel-buffering": "no",
481
+ });
482
+ // A reverse proxy or an idle-socket timeout would otherwise cut a stream
483
+ // that is deliberately quiet between commits.
484
+ res.socket?.setNoDelay(true);
485
+ res.socket?.setTimeout(0);
486
+
487
+ const client = { res };
488
+ clients.add(client);
489
+
490
+ const header = req.headers["last-event-id"];
491
+ const fromQuery = url.searchParams.get("lastEventId");
492
+ const resumeFrom = Number(header ?? fromQuery ?? 0);
493
+ const resume = Number.isFinite(resumeFrom) && resumeFrom > 0 ? Math.floor(resumeFrom) : 0;
494
+
495
+ write(client, `event: hello\ndata: ${JSON.stringify({ sequence: lastSequence })}\n\n`);
496
+
497
+ // Arriving into a stream that is already blind. Saying hello and nothing
498
+ // else would let this client draw the same wrong conclusion as the ones
499
+ // that were here when it broke.
500
+ if (degraded) {
501
+ write(
502
+ client,
503
+ `event: degraded\ndata: ${JSON.stringify({
504
+ sequence: lastSequence,
505
+ reason: "the change poller is not reaching the project database",
506
+ })}\n\n`,
507
+ );
508
+ }
509
+
510
+ if (resume > 0 && resume < lastSequence) {
511
+ const oldest = replay.length ? replay[0].sequence : lastSequence + 1;
512
+ if (resume + 1 < oldest) {
513
+ // Their position fell out of the buffer, so an incremental catch-up
514
+ // would silently skip changes. Ask for a full reload instead.
515
+ write(client, `event: resync\ndata: ${JSON.stringify({ sequence: lastSequence })}\n\n`);
516
+ } else {
517
+ for (const event of replay) {
518
+ if (event.sequence > resume) write(client, frame(event));
519
+ }
520
+ }
521
+ }
522
+
523
+ const done = () => clients.delete(client);
524
+ req.on("close", done);
525
+ req.on("error", done);
526
+ res.on("close", done);
527
+ }
528
+
529
+ const frame = (event) =>
530
+ `id: ${event.sequence}\nevent: change\ndata: ${JSON.stringify(event)}\n\n`;
531
+
532
+ function broadcast(event) {
533
+ replay.push(event);
534
+ if (replay.length > settings.replayBuffer) replay.splice(0, replay.length - settings.replayBuffer);
535
+ broadcastRaw(frame(event));
536
+ }
537
+
538
+ /** For frames that are about the stream itself rather than a recorded change. */
539
+ function broadcastRaw(text) {
540
+ for (const client of clients) write(client, text);
541
+ }
542
+
543
+ /**
544
+ * Announce only what actually moved. Emitting on every poll would make the
545
+ * interface refetch once a second forever, which is exactly the busy-work
546
+ * this stream exists to avoid.
547
+ */
548
+ async function poll() {
549
+ if (closing || writesInFlight) return;
550
+ let rows;
551
+ try {
552
+ rows = await query(root, async (db) => {
553
+ const latest = (await db.value("SELECT max(sequence) FROM activity_events")) ?? 0;
554
+ if (latest <= lastSequence) return null;
555
+ return db.all(
556
+ `SELECT sequence, event_type, task_id, feature_id, summary
557
+ FROM activity_events WHERE sequence > ? ORDER BY sequence LIMIT ?`,
558
+ lastSequence,
559
+ settings.replayBuffer,
560
+ );
561
+ });
562
+ } catch (error) {
563
+ // A locked or half-written database is a transient state during another
564
+ // process's commit, and the next tick sees the committed result. A fault
565
+ // that keeps returning is not that. Swallowing it forever left the socket
566
+ // open and the interface calling itself live while it had gone blind, so
567
+ // after enough consecutive failures the stream says so.
568
+ pollFailures += 1;
569
+ if (pollFailures === DEGRADE_AFTER) {
570
+ degraded = true;
571
+ broadcastRaw(
572
+ `event: degraded\ndata: ${JSON.stringify({
573
+ sequence: lastSequence,
574
+ reason: String(error?.message ?? error).slice(0, 200),
575
+ })}\n\n`,
576
+ );
577
+ }
578
+ return;
579
+ }
580
+ if (pollFailures > 0) {
581
+ pollFailures = 0;
582
+ if (degraded) {
583
+ degraded = false;
584
+ broadcastRaw(`event: recovered\ndata: ${JSON.stringify({ sequence: lastSequence })}\n\n`);
585
+ }
586
+ }
587
+ if (!rows?.length) return;
588
+ for (const row of rows) {
589
+ lastSequence = row.sequence;
590
+ broadcast({
591
+ sequence: row.sequence,
592
+ eventType: row.event_type,
593
+ summary: row.summary,
594
+ taskId: row.task_id,
595
+ featureId: row.feature_id,
596
+ scopes: scopesFor(row.event_type),
597
+ });
598
+ }
599
+ }
600
+
601
+ // ------------------------------------------------------------------- pages
602
+
603
+ async function sendPage(res, headOnly = false) {
604
+ if (!existsSync(BUNDLE)) return sendHtml(res, 200, FALLBACK_PAGE, headOnly);
605
+ let html = readFileSync(BUNDLE, "utf8");
606
+ let bootstrap = "null";
607
+ try {
608
+ // The first paint has real data rather than a spinner. A failure here is
609
+ // never fatal: the page fetches the same route on mount anyway.
610
+ bootstrap = escapeForScript(JSON.stringify(await readModel.overview(root)));
611
+ } catch {
612
+ bootstrap = "null";
613
+ }
614
+ if (html.includes(DATA_PLACEHOLDER)) html = html.replace(DATA_PLACEHOLDER, bootstrap);
615
+ return sendHtml(res, 200, html, headOnly);
616
+ }
617
+
618
+ // ------------------------------------------------------------------ timers
619
+
620
+ const boundPort = await listen(server, settings.host, settings.port, settings.portScan);
621
+
622
+ lastSequence =
623
+ (await query(root, (db) => db.value("SELECT max(sequence) FROM activity_events")).catch(() => 0)) ?? 0;
624
+
625
+ const pollTimer = setInterval(() => {
626
+ poll().catch(() => {});
627
+ }, settings.pollMs);
628
+ pollTimer.unref();
629
+
630
+ const heartbeat = setInterval(() => {
631
+ for (const client of clients) write(client, `: keepalive ${Date.now()}\n\n`);
632
+ }, settings.heartbeatMs);
633
+ heartbeat.unref();
634
+
635
+ let idleTimer = null;
636
+ if (settings.idleTimeoutMs > 0) {
637
+ // A short idle window has to be checked more often than the default, or the
638
+ // configured timeout would silently round up to the check interval.
639
+ const idleCheck = Math.max(1000, Math.min(settings.idleCheckMs, Math.floor(settings.idleTimeoutMs / 4)));
640
+ idleTimer = setInterval(() => {
641
+ // Idle means nobody asked for anything and nobody is listening. A tab
642
+ // holding the events stream open counts as someone using the service.
643
+ if (clients.size) return;
644
+ if (Date.now() - lastRequestAt < settings.idleTimeoutMs) return;
645
+ close().catch(() => {});
646
+ }, idleCheck);
647
+ idleTimer.unref();
648
+ }
649
+
650
+ async function close() {
651
+ if (closing) return;
652
+ closing = true;
653
+ clearInterval(pollTimer);
654
+ clearInterval(heartbeat);
655
+ if (idleTimer) clearInterval(idleTimer);
656
+ for (const client of clients) {
657
+ try {
658
+ client.res.end();
659
+ } catch {
660
+ // The socket is already gone, which is the state we wanted anyway.
661
+ }
662
+ }
663
+ clients.clear();
664
+ await new Promise((resolve) => server.close(resolve));
665
+ if (typeof settings.onClose === "function") settings.onClose();
666
+ }
667
+
668
+ return {
669
+ port: boundPort,
670
+ host: settings.host,
671
+ url: `http://${settings.host}:${boundPort}`,
672
+ instanceToken,
673
+ version: VERSION,
674
+ projectRoot: root,
675
+ close,
676
+ };
677
+ }
678
+
679
+ /** Which views a change touches, so a listener refetches only what it shows. */
680
+ function scopesFor(eventType) {
681
+ const type = String(eventType ?? "");
682
+ if (type.startsWith("task_")) return ["overview", "tasks", "activity", "product"];
683
+ if (type.startsWith("session_")) return ["overview", "team", "activity"];
684
+ if (type.startsWith("decision_")) return ["decisions", "activity", "readiness"];
685
+ if (type.startsWith("documentation_")) return ["readiness", "activity"];
686
+ if (type === "scope_changed") return ["discovery", "product", "overview", "activity"];
687
+ if (type === "verification_attached") return ["tasks", "features", "readiness", "activity"];
688
+ return ["overview", "activity", "product", "readiness"];
689
+ }
690
+
691
+ /**
692
+ * Bind the requested port, then scan upward. Another project's service on the
693
+ * default port is the ordinary case, not an error, so the second one moves up
694
+ * rather than refusing to start.
695
+ */
696
+ function listen(server, host, port, scan) {
697
+ return new Promise((resolve, reject) => {
698
+ let candidate = port;
699
+ let attempts = 0;
700
+
701
+ const onError = (err) => {
702
+ if (err.code !== "EADDRINUSE" || attempts >= scan) {
703
+ server.removeListener("error", onError);
704
+ reject(
705
+ err.code === "EADDRINUSE"
706
+ ? Object.assign(
707
+ new Error(
708
+ `Ports ${port} to ${port + scan} are all in use on ${host}. Stop another service or start this one with an explicit port.`,
709
+ ),
710
+ { code: "E_NO_FREE_PORT" },
711
+ )
712
+ : err,
713
+ );
714
+ return;
715
+ }
716
+ attempts += 1;
717
+ candidate += 1;
718
+ server.listen(candidate, host);
719
+ };
720
+
721
+ server.on("error", onError);
722
+ server.once("listening", () => {
723
+ server.removeListener("error", onError);
724
+ resolve(server.address().port);
725
+ });
726
+ server.listen(candidate, host);
727
+ });
728
+ }
729
+
730
+ // --------------------------------------------------------------- entry point
731
+
732
+ /**
733
+ * Started detached by manage.mjs. It writes the lock file with the port it
734
+ * actually bound so the launcher, and later `superdev services`, can find it,
735
+ * and removes the lock on the way out so a crash is the only way to leave one
736
+ * behind.
737
+ */
738
+ async function main(argv) {
739
+ const arg = (name, fallback = null) => {
740
+ const index = argv.indexOf(`--${name}`);
741
+ return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
742
+ };
743
+ const root = arg("root", process.cwd());
744
+ const port = Number(arg("port", DEFAULTS.port));
745
+ const host = arg("host", DEFAULTS.host);
746
+ const idle = Number(arg("idle-timeout", DEFAULTS.idleTimeoutMs));
747
+
748
+ const { publish, retract } = await import("./manage.mjs");
749
+ const service = await serve(root, {
750
+ port: Number.isFinite(port) ? port : DEFAULTS.port,
751
+ host,
752
+ idleTimeoutMs: Number.isFinite(idle) ? idle : DEFAULTS.idleTimeoutMs,
753
+ instanceToken: process.env.SUPERDEV_INSTANCE_TOKEN || undefined,
754
+ onClose: () => retract(root),
755
+ });
756
+
757
+ publish(root, {
758
+ pid: process.pid,
759
+ port: service.port,
760
+ host: service.host,
761
+ projectRoot: root,
762
+ instanceToken: service.instanceToken,
763
+ version: service.version,
764
+ startedAt: new Date().toISOString(),
765
+ });
766
+
767
+ const shutdown = () => {
768
+ service.close().then(
769
+ () => process.exit(0),
770
+ () => process.exit(1),
771
+ );
772
+ };
773
+ process.on("SIGTERM", shutdown);
774
+ process.on("SIGINT", shutdown);
775
+ process.stdout.write(`superdev service listening on ${service.url} for ${root}\n`);
776
+ }
777
+
778
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
779
+ main(process.argv.slice(2)).catch((err) => {
780
+ process.stderr.write(`${err?.message ?? err}\n`);
781
+ process.exit(1);
782
+ });
783
+ }