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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- package/src/verify/index.mjs +236 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// Starting, stopping and finding the local service.
|
|
2
|
+
//
|
|
3
|
+
// The lock file is a real mutex: it is created with the exclusive `wx` flag
|
|
4
|
+
// before anything binds a port, so two launchers racing in the same directory
|
|
5
|
+
// cannot both win. A PID existence check is not a mutex, because process ids are
|
|
6
|
+
// reused and the process wearing one a minute later is somebody else's.
|
|
7
|
+
//
|
|
8
|
+
// Liveness is therefore never "is that pid alive". It is an HTTP GET of /health
|
|
9
|
+
// that has to echo back the same project root and the same instance token. A
|
|
10
|
+
// lock whose service does not answer that is stale by definition, so it is
|
|
11
|
+
// removed and the acquire is retried exactly once. Anything that answers on the
|
|
12
|
+
// port but is not this project's service is a state of its own, not an error to
|
|
13
|
+
// paper over.
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { request } from "node:http";
|
|
18
|
+
import {
|
|
19
|
+
chmodSync,
|
|
20
|
+
closeSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
openSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
renameSync,
|
|
27
|
+
unlinkSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
writeSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
|
|
35
|
+
import { paths } from "../db/store.mjs";
|
|
36
|
+
import { DEFAULTS } from "./server.mjs";
|
|
37
|
+
|
|
38
|
+
const SERVER = join(dirname(fileURLToPath(import.meta.url)), "server.mjs");
|
|
39
|
+
|
|
40
|
+
/** Every runtime file is owner-only: a log can quote a project's own content. */
|
|
41
|
+
const PRIVATE = 0o600;
|
|
42
|
+
|
|
43
|
+
/** How long a launcher waits for the child to answer /health before giving up. */
|
|
44
|
+
const START_TIMEOUT_MS = 15_000;
|
|
45
|
+
/** How long a lock with no port yet is treated as a start in progress. */
|
|
46
|
+
const STARTING_GRACE_MS = 20_000;
|
|
47
|
+
const STOP_TIMEOUT_MS = 10_000;
|
|
48
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
49
|
+
|
|
50
|
+
export const E = {
|
|
51
|
+
RUNNING: "E_SERVICE_RUNNING",
|
|
52
|
+
FOREIGN: "E_FOREIGN_SERVICE",
|
|
53
|
+
START_FAILED: "E_SERVICE_START_FAILED",
|
|
54
|
+
STOP_FAILED: "E_SERVICE_STOP_FAILED",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export class ServiceError extends Error {
|
|
58
|
+
constructor(code, message, detail) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "ServiceError";
|
|
61
|
+
this.code = code;
|
|
62
|
+
if (detail !== undefined) this.detail = detail;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lockPath = (root) => join(paths(root).runtime, "service.lock");
|
|
67
|
+
const logPath = (root) => join(paths(root).runtime, "service.log");
|
|
68
|
+
|
|
69
|
+
/** One file per project under the user's home, so listing crosses projects. */
|
|
70
|
+
const registryDir = () => join(homedir(), ".superdev", "services");
|
|
71
|
+
const registryPath = (root) =>
|
|
72
|
+
join(registryDir(), `${createHash("sha256").update(root).digest("hex").slice(0, 16)}.json`);
|
|
73
|
+
|
|
74
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
75
|
+
|
|
76
|
+
function readJson(file) {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Write through a temporary file so a reader never sees a half-written record. */
|
|
85
|
+
function writeAtomic(file, value) {
|
|
86
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
87
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
88
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: PRIVATE });
|
|
89
|
+
renameSync(tmp, file);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const remove = (file) => {
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(file);
|
|
95
|
+
} catch {
|
|
96
|
+
// Already gone, which is the state the caller wanted.
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ------------------------------------------------------------------ liveness
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ask the service on a port who it is. Returns the health body, or null when
|
|
104
|
+
* nothing answers, the answer is not JSON, or it takes too long.
|
|
105
|
+
*/
|
|
106
|
+
export function probeHealth(host, port, timeout = PROBE_TIMEOUT_MS) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
const req = request(
|
|
109
|
+
{ host, port, path: "/health", method: "GET", timeout, headers: { accept: "application/json" } },
|
|
110
|
+
(res) => {
|
|
111
|
+
if (res.statusCode !== 200) {
|
|
112
|
+
res.resume();
|
|
113
|
+
resolve(null);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let body = "";
|
|
117
|
+
res.setEncoding("utf8");
|
|
118
|
+
res.on("data", (chunk) => {
|
|
119
|
+
body += chunk;
|
|
120
|
+
// A health body is tiny. Anything larger is not this service.
|
|
121
|
+
if (body.length > 8192) req.destroy();
|
|
122
|
+
});
|
|
123
|
+
res.on("end", () => {
|
|
124
|
+
try {
|
|
125
|
+
resolve(JSON.parse(body));
|
|
126
|
+
} catch {
|
|
127
|
+
resolve(null);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
req.on("timeout", () => req.destroy());
|
|
133
|
+
req.on("error", () => resolve(null));
|
|
134
|
+
req.end();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What is actually true for this project right now. Distinguishes a service we
|
|
140
|
+
* manage from a stale lock, from a start in progress, from someone else holding
|
|
141
|
+
* the port.
|
|
142
|
+
*/
|
|
143
|
+
export async function inspect(root, { port = null, host = DEFAULTS.host } = {}) {
|
|
144
|
+
const lock = readJson(lockPath(root));
|
|
145
|
+
|
|
146
|
+
if (!lock) {
|
|
147
|
+
// No lock does not mean nothing is listening: another project's service, or
|
|
148
|
+
// an unrelated program, may already hold the port we would ask for.
|
|
149
|
+
const candidate = port ?? DEFAULTS.port;
|
|
150
|
+
const health = await probeHealth(host, candidate);
|
|
151
|
+
if (health && health.projectRoot === root) {
|
|
152
|
+
return {
|
|
153
|
+
state: "running",
|
|
154
|
+
managed: false,
|
|
155
|
+
lock: null,
|
|
156
|
+
health,
|
|
157
|
+
port: candidate,
|
|
158
|
+
host,
|
|
159
|
+
explanation: `A Superdev service for this project is answering on port ${candidate} but this directory has no lock file for it.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (health) {
|
|
163
|
+
return {
|
|
164
|
+
state: "foreign",
|
|
165
|
+
managed: false,
|
|
166
|
+
lock: null,
|
|
167
|
+
health,
|
|
168
|
+
port: candidate,
|
|
169
|
+
host,
|
|
170
|
+
explanation: `Port ${candidate} is held by a Superdev service for a different project. Start this one on another port.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
state: "stopped",
|
|
175
|
+
managed: false,
|
|
176
|
+
lock: null,
|
|
177
|
+
health: null,
|
|
178
|
+
port: null,
|
|
179
|
+
host,
|
|
180
|
+
explanation: "No service is running for this project.",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!lock.port) {
|
|
185
|
+
const age = Date.now() - Date.parse(lock.startedAt ?? "");
|
|
186
|
+
const starting = Number.isFinite(age) && age < STARTING_GRACE_MS;
|
|
187
|
+
return {
|
|
188
|
+
state: starting ? "starting" : "stale",
|
|
189
|
+
managed: true,
|
|
190
|
+
lock,
|
|
191
|
+
health: null,
|
|
192
|
+
port: null,
|
|
193
|
+
host: lock.host ?? host,
|
|
194
|
+
explanation: starting
|
|
195
|
+
? "A service for this project is starting."
|
|
196
|
+
: "A lock file is left over from a service that never finished starting. Run `superdev stop` to clear it.",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const health = await probeHealth(lock.host ?? host, lock.port);
|
|
201
|
+
if (!health) {
|
|
202
|
+
return {
|
|
203
|
+
state: "stale",
|
|
204
|
+
managed: true,
|
|
205
|
+
lock,
|
|
206
|
+
health: null,
|
|
207
|
+
port: lock.port,
|
|
208
|
+
host: lock.host ?? host,
|
|
209
|
+
explanation: `The lock file names a service on port ${lock.port} that is not answering. It is stale and will be cleared on the next start.`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (health.projectRoot !== root || health.instanceToken !== lock.instanceToken) {
|
|
213
|
+
return {
|
|
214
|
+
state: "foreign",
|
|
215
|
+
managed: false,
|
|
216
|
+
lock,
|
|
217
|
+
health,
|
|
218
|
+
port: lock.port,
|
|
219
|
+
host: lock.host ?? host,
|
|
220
|
+
explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes. Something else took the port.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
state: "running",
|
|
225
|
+
managed: true,
|
|
226
|
+
lock,
|
|
227
|
+
health,
|
|
228
|
+
port: lock.port,
|
|
229
|
+
host: lock.host ?? host,
|
|
230
|
+
explanation: `The service for this project is running on port ${lock.port}.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const summarize = (root, view) => ({
|
|
235
|
+
state: view.state,
|
|
236
|
+
managed: view.managed,
|
|
237
|
+
projectRoot: root,
|
|
238
|
+
pid: view.lock?.pid ?? null,
|
|
239
|
+
port: view.port,
|
|
240
|
+
host: view.host,
|
|
241
|
+
url: view.port ? `http://${view.host}:${view.port}` : null,
|
|
242
|
+
instanceToken: view.health?.instanceToken ?? view.lock?.instanceToken ?? null,
|
|
243
|
+
version: view.health?.version ?? view.lock?.version ?? null,
|
|
244
|
+
startedAt: view.lock?.startedAt ?? null,
|
|
245
|
+
logPath: logPath(root),
|
|
246
|
+
explanation: view.explanation,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export async function serviceStatus(root, options = {}) {
|
|
250
|
+
return summarize(root, await inspect(root, options));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ------------------------------------------------------------------- the lock
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Take the mutex. `wx` fails rather than truncating when the file exists, which
|
|
257
|
+
* is what makes this a lock instead of a race. On a collision the existing lock
|
|
258
|
+
* is checked for liveness once; a dead one is removed and the acquire is retried
|
|
259
|
+
* exactly once, so a permanently unwritable directory cannot spin.
|
|
260
|
+
*/
|
|
261
|
+
async function acquire(root, record, options) {
|
|
262
|
+
mkdirSync(paths(root).runtime, { recursive: true });
|
|
263
|
+
const file = lockPath(root);
|
|
264
|
+
|
|
265
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
266
|
+
let fd;
|
|
267
|
+
try {
|
|
268
|
+
fd = openSync(file, "wx", PRIVATE);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err.code !== "EEXIST" || attempt === 1) throw err;
|
|
271
|
+
const view = await inspect(root, options);
|
|
272
|
+
if (view.state === "running" || view.state === "starting") return { held: false, view };
|
|
273
|
+
if (view.state === "foreign") return { held: false, view };
|
|
274
|
+
remove(file);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
writeSync(fd, `${JSON.stringify(record, null, 2)}\n`);
|
|
279
|
+
} finally {
|
|
280
|
+
closeSync(fd);
|
|
281
|
+
}
|
|
282
|
+
return { held: true, view: null };
|
|
283
|
+
}
|
|
284
|
+
return { held: false, view: await inspect(root, options) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Called by the service once it has bound, so the lock names a real port. */
|
|
288
|
+
export function publish(root, info) {
|
|
289
|
+
writeAtomic(lockPath(root), { ...info, state: "running" });
|
|
290
|
+
writeAtomic(registryPath(root), { ...info, state: "running" });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Called by the service on the way out. A crash is the only way to skip it. */
|
|
294
|
+
export function retract(root) {
|
|
295
|
+
remove(lockPath(root));
|
|
296
|
+
remove(registryPath(root));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ------------------------------------------------------------------- control
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Start the service detached, with its output going to an owner-only log, and
|
|
303
|
+
* wait until /health actually answers. A process that spawned but never bound is
|
|
304
|
+
* a failure, not a success with a delay.
|
|
305
|
+
*/
|
|
306
|
+
export async function startService(root, options = {}) {
|
|
307
|
+
const host = options.host ?? DEFAULTS.host;
|
|
308
|
+
const port = options.port ?? DEFAULTS.port;
|
|
309
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULTS.idleTimeoutMs;
|
|
310
|
+
const timeout = options.timeoutMs ?? START_TIMEOUT_MS;
|
|
311
|
+
// Identity, not a credential. It answers one question: is the service now
|
|
312
|
+
// answering on this port the one this manager started for this project, or
|
|
313
|
+
// somebody else's process that took the port after ours died. `/health` hands
|
|
314
|
+
// it to anyone who asks and the browser never sends it, so it must never be
|
|
315
|
+
// treated as proof that a caller is allowed to do something. What actually
|
|
316
|
+
// bounds access is the loopback bind, the Host check and the origin check.
|
|
317
|
+
const instanceToken = createHash("sha256")
|
|
318
|
+
.update(`${root}:${Date.now()}:${Math.random()}`)
|
|
319
|
+
.digest("hex")
|
|
320
|
+
.slice(0, 32);
|
|
321
|
+
|
|
322
|
+
const existing = await inspect(root, { port, host });
|
|
323
|
+
if (existing.state === "running") {
|
|
324
|
+
return { ...summarize(root, existing), alreadyRunning: true };
|
|
325
|
+
}
|
|
326
|
+
if (existing.state === "starting") {
|
|
327
|
+
return { ...summarize(root, existing), alreadyRunning: true };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const startedAt = new Date().toISOString();
|
|
331
|
+
const { held, view } = await acquire(
|
|
332
|
+
root,
|
|
333
|
+
{ projectRoot: root, instanceToken, startedAt, pid: null, port: null, host, state: "starting" },
|
|
334
|
+
{ port, host },
|
|
335
|
+
);
|
|
336
|
+
if (!held) {
|
|
337
|
+
if (view.state === "foreign") {
|
|
338
|
+
throw new ServiceError(E.FOREIGN, view.explanation, { port: view.port });
|
|
339
|
+
}
|
|
340
|
+
return { ...summarize(root, view), alreadyRunning: true };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const log = logPath(root);
|
|
344
|
+
const fd = openSync(log, "a", PRIVATE);
|
|
345
|
+
// A log created under an older umask could be group readable, and it can quote
|
|
346
|
+
// the project's own content, so the mode is asserted rather than assumed.
|
|
347
|
+
chmodSync(log, PRIVATE);
|
|
348
|
+
|
|
349
|
+
let child;
|
|
350
|
+
try {
|
|
351
|
+
child = spawn(
|
|
352
|
+
process.execPath,
|
|
353
|
+
[
|
|
354
|
+
SERVER,
|
|
355
|
+
"--root", root,
|
|
356
|
+
"--port", String(port),
|
|
357
|
+
"--host", host,
|
|
358
|
+
"--idle-timeout", String(idleTimeoutMs),
|
|
359
|
+
],
|
|
360
|
+
{
|
|
361
|
+
cwd: root,
|
|
362
|
+
detached: true,
|
|
363
|
+
stdio: ["ignore", fd, fd],
|
|
364
|
+
env: { ...process.env, SUPERDEV_INSTANCE_TOKEN: instanceToken },
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
closeSync(fd);
|
|
369
|
+
remove(lockPath(root));
|
|
370
|
+
throw new ServiceError(E.START_FAILED, `The service could not be started: ${err.message}`);
|
|
371
|
+
}
|
|
372
|
+
closeSync(fd);
|
|
373
|
+
child.unref();
|
|
374
|
+
|
|
375
|
+
let exited = null;
|
|
376
|
+
child.once("exit", (code, signal) => {
|
|
377
|
+
exited = { code, signal };
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const deadline = Date.now() + timeout;
|
|
381
|
+
while (Date.now() < deadline) {
|
|
382
|
+
const lock = readJson(lockPath(root));
|
|
383
|
+
if (lock?.port) {
|
|
384
|
+
const health = await probeHealth(lock.host ?? host, lock.port);
|
|
385
|
+
if (health?.projectRoot === root && health.instanceToken === instanceToken) {
|
|
386
|
+
return { ...summarize(root, await inspect(root, { port, host })), alreadyRunning: false };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (exited) break;
|
|
390
|
+
await sleep(120);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
process.kill(child.pid, "SIGTERM");
|
|
395
|
+
} catch {
|
|
396
|
+
// It is already gone, which is what the failure path wants anyway.
|
|
397
|
+
}
|
|
398
|
+
remove(lockPath(root));
|
|
399
|
+
remove(registryPath(root));
|
|
400
|
+
throw new ServiceError(
|
|
401
|
+
E.START_FAILED,
|
|
402
|
+
exited
|
|
403
|
+
? `The service exited immediately (${exited.signal ?? `code ${exited.code}`}). The reason is at the end of ${log}.`
|
|
404
|
+
: `The service did not answer /health within ${Math.round(timeout / 1000)} seconds. Check ${log}.`,
|
|
405
|
+
{ log, tail: tailOf(log) },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The last few lines of the log, which is where the reason for a failure is. */
|
|
410
|
+
function tailOf(file, lines = 12) {
|
|
411
|
+
try {
|
|
412
|
+
return readFileSync(file, "utf8").trimEnd().split("\n").slice(-lines).join("\n");
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Stop the service. The pid is only ever signalled after /health has just
|
|
420
|
+
* confirmed that this pid's service is ours, so the pid-reuse hazard is closed
|
|
421
|
+
* by the check rather than by hoping.
|
|
422
|
+
*/
|
|
423
|
+
export async function stopService(root, options = {}) {
|
|
424
|
+
const view = await inspect(root, options);
|
|
425
|
+
|
|
426
|
+
if (view.state === "stopped") {
|
|
427
|
+
return { ...summarize(root, view), alreadyStopped: true };
|
|
428
|
+
}
|
|
429
|
+
if (view.state === "stale" || view.state === "starting") {
|
|
430
|
+
remove(lockPath(root));
|
|
431
|
+
remove(registryPath(root));
|
|
432
|
+
return {
|
|
433
|
+
...summarize(root, view),
|
|
434
|
+
state: "stopped",
|
|
435
|
+
clearedStaleLock: true,
|
|
436
|
+
explanation: "There was no live service, so the leftover lock file was cleared.",
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (view.state === "foreign") {
|
|
440
|
+
throw new ServiceError(E.FOREIGN, view.explanation, { port: view.port });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const pid = view.lock?.pid;
|
|
444
|
+
if (!pid) {
|
|
445
|
+
throw new ServiceError(
|
|
446
|
+
E.STOP_FAILED,
|
|
447
|
+
`The service on port ${view.port} is answering but its lock file records no process id, so it cannot be stopped from here. Stop it in the terminal that started it.`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
process.kill(pid, "SIGTERM");
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (err.code !== "ESRCH") throw err;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const deadline = Date.now() + (options.timeoutMs ?? STOP_TIMEOUT_MS);
|
|
458
|
+
while (Date.now() < deadline) {
|
|
459
|
+
if (!(await probeHealth(view.host, view.port))) {
|
|
460
|
+
remove(lockPath(root));
|
|
461
|
+
remove(registryPath(root));
|
|
462
|
+
return {
|
|
463
|
+
...summarize(root, view),
|
|
464
|
+
state: "stopped",
|
|
465
|
+
explanation: `The service on port ${view.port} was stopped.`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
await sleep(120);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
throw new ServiceError(
|
|
472
|
+
E.STOP_FAILED,
|
|
473
|
+
`The service on port ${view.port} is still answering after a shutdown request. Stop process ${pid} directly, then run this again.`,
|
|
474
|
+
{ pid, port: view.port },
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function restartService(root, options = {}) {
|
|
479
|
+
await stopService(root, options).catch((err) => {
|
|
480
|
+
if (err.code !== E.FOREIGN) throw err;
|
|
481
|
+
return null;
|
|
482
|
+
});
|
|
483
|
+
return startService(root, options);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Every service this machine knows about. The registry is written on start and
|
|
488
|
+
* removed on a clean stop, so a crashed process leaves an entry behind; each one
|
|
489
|
+
* is probed and the dead entries are cleared as a side effect of listing.
|
|
490
|
+
*/
|
|
491
|
+
export async function listServices() {
|
|
492
|
+
const dir = registryDir();
|
|
493
|
+
if (!existsSync(dir)) return [];
|
|
494
|
+
|
|
495
|
+
const out = [];
|
|
496
|
+
for (const name of readdirSync(dir)) {
|
|
497
|
+
if (!name.endsWith(".json")) continue;
|
|
498
|
+
const file = join(dir, name);
|
|
499
|
+
const record = readJson(file);
|
|
500
|
+
if (!record?.projectRoot) {
|
|
501
|
+
remove(file);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const health = await probeHealth(record.host ?? DEFAULTS.host, record.port);
|
|
505
|
+
const live = health?.projectRoot === record.projectRoot && health.instanceToken === record.instanceToken;
|
|
506
|
+
if (!live) {
|
|
507
|
+
remove(file);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
out.push({
|
|
511
|
+
state: "running",
|
|
512
|
+
projectRoot: record.projectRoot,
|
|
513
|
+
pid: record.pid ?? null,
|
|
514
|
+
port: record.port,
|
|
515
|
+
host: record.host ?? DEFAULTS.host,
|
|
516
|
+
url: `http://${record.host ?? DEFAULTS.host}:${record.port}`,
|
|
517
|
+
instanceToken: record.instanceToken,
|
|
518
|
+
version: health.version ?? record.version ?? null,
|
|
519
|
+
startedAt: record.startedAt ?? null,
|
|
520
|
+
logPath: logPath(record.projectRoot),
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return out.sort((a, b) => a.projectRoot.localeCompare(b.projectRoot));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Refuse work that would move the database underneath a running service. The
|
|
528
|
+
* service holds no connection open, but a migration or a restore swaps the file
|
|
529
|
+
* a live reader is about to open, and a half-answered interface is worse than a
|
|
530
|
+
* clear refusal.
|
|
531
|
+
*/
|
|
532
|
+
export async function assertNoLiveService(root, options = {}) {
|
|
533
|
+
const view = await inspect(root, options);
|
|
534
|
+
if (view.state === "running" || view.state === "starting") {
|
|
535
|
+
throw new ServiceError(
|
|
536
|
+
E.RUNNING,
|
|
537
|
+
`The Superdev service for this project is ${view.state === "starting" ? "starting" : `running on port ${view.port}`}. Stop it with \`superdev stop\` before changing the database, then start it again.`,
|
|
538
|
+
{ port: view.port, pid: view.lock?.pid ?? null },
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return true;
|
|
542
|
+
}
|