@yugabytedb/meko-mcp 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +505 -0
  2. package/assets/hooks-handlers/lib/capture.js +863 -0
  3. package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
  4. package/assets/hooks-handlers/pre-compact.sh +3 -0
  5. package/assets/hooks-handlers/session-end.sh +27 -0
  6. package/assets/hooks-handlers/session-start.sh +24 -0
  7. package/assets/hooks-handlers/test_single_conversation.sh +169 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
  11. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
  12. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
  13. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
  14. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
  15. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
  16. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
  17. package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
  18. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
  19. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
  22. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
  23. package/bin/create-meko-setup.mjs +499 -0
  24. package/lib/claude-code.mjs +14 -0
  25. package/lib/clients/claude-code.mjs +265 -0
  26. package/lib/clients/claude-desktop.mjs +256 -0
  27. package/lib/clients/common.mjs +107 -0
  28. package/lib/clients/cursor.mjs +332 -0
  29. package/lib/clients/index.mjs +14 -0
  30. package/lib/config.mjs +369 -0
  31. package/lib/detect.mjs +125 -0
  32. package/lib/installer.mjs +255 -0
  33. package/lib/logger.mjs +97 -0
  34. package/lib/mcp-transport.mjs +204 -0
  35. package/lib/migrate/adapters/claude-code.mjs +165 -0
  36. package/lib/migrate/adapters/cursor.mjs +221 -0
  37. package/lib/migrate/adapters/memory-md.mjs +230 -0
  38. package/lib/migrate/claude-parser.mjs +306 -0
  39. package/lib/migrate/daemon.mjs +67 -0
  40. package/lib/migrate/id.mjs +83 -0
  41. package/lib/migrate/lockfile.mjs +121 -0
  42. package/lib/migrate/logger.mjs +79 -0
  43. package/lib/migrate/mcp-client.mjs +224 -0
  44. package/lib/migrate/orchestrator.mjs +443 -0
  45. package/lib/migrate/progress.mjs +76 -0
  46. package/lib/migrate/state.mjs +177 -0
  47. package/lib/migrate.mjs +328 -0
  48. package/lib/prompt.mjs +212 -0
  49. package/lib/validate.mjs +259 -0
  50. package/package.json +49 -0
  51. package/scripts/bundle-assets.mjs +49 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * progress.mjs — Lightweight progress snapshots for `--migrate-status`.
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ /**
9
+ * @typedef {Object} Progress
10
+ * @property {string} runId
11
+ * @property {string} scope
12
+ * @property {string} startedAt
13
+ * @property {string|null} finishedAt
14
+ * @property {"running"|"done"|"failed"|"interrupted"} status
15
+ * @property {string|null} currentAgent
16
+ * @property {string|null} currentSession
17
+ * @property {Counts} counts
18
+ *
19
+ * @typedef {Object} Counts
20
+ * @property {number} sessionsPlanned
21
+ * @property {number} sessionsDone
22
+ * @property {number} messagesSent
23
+ * @property {number} memoryPairsSent
24
+ * @property {number} memoriesFromMD
25
+ * @property {number} warnings
26
+ * @property {number} memoryErrors
27
+ */
28
+
29
+ /**
30
+ * @returns {Progress}
31
+ */
32
+ export function initial(runId, scope) {
33
+ return {
34
+ runId,
35
+ scope,
36
+ startedAt: new Date().toISOString(),
37
+ finishedAt: null,
38
+ status: "running",
39
+ currentAgent: null,
40
+ currentSession: null,
41
+ counts: {
42
+ sessionsPlanned: 0,
43
+ sessionsDone: 0,
44
+ messagesSent: 0,
45
+ memoryPairsSent: 0,
46
+ memoriesFromMD: 0,
47
+ warnings: 0,
48
+ memoryErrors: 0,
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Atomic write.
55
+ *
56
+ * @param {string} progressPath
57
+ * @param {Progress} progress
58
+ */
59
+ export function write(progressPath, progress) {
60
+ fs.mkdirSync(path.dirname(progressPath), { recursive: true });
61
+ const tmp = `${progressPath}.tmp.${process.pid}.${Date.now()}`;
62
+ fs.writeFileSync(tmp, JSON.stringify(progress, null, 2));
63
+ fs.renameSync(tmp, progressPath);
64
+ }
65
+
66
+ /**
67
+ * @param {string} progressPath
68
+ * @returns {Progress | null}
69
+ */
70
+ export function read(progressPath) {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(progressPath, "utf8"));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * state.mjs — Durable cross-run state for the migrator.
3
+ *
4
+ * File layout documented in the feature plan. Writes are atomic
5
+ * (tmp + rename) and debounced by the caller via `flushNow()` / periodic.
6
+ */
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ export const STATE_VERSION = 1;
12
+
13
+ /**
14
+ * @typedef {Object} State
15
+ * @property {number} version
16
+ * @property {string} updatedAt
17
+ * @property {Array<object>} runs
18
+ * @property {Record<string, AgentState>} agents
19
+ *
20
+ * @typedef {Object} AgentState
21
+ * @property {string|null} memoryConversationId
22
+ * @property {Record<string, ConversationState>} conversations
23
+ * @property {Record<string, MemoryState>} memories
24
+ *
25
+ * @typedef {Object} ConversationState
26
+ * @property {string} conversationId
27
+ * @property {"pending"|"partial"|"done"|"failed"} status
28
+ * @property {number} messagesSent
29
+ * @property {number} memoryPairsSent
30
+ * @property {string|null} lastUuid
31
+ * @property {string|null} completedAt
32
+ * @property {string|null} lastError
33
+ *
34
+ * @typedef {Object} MemoryState
35
+ * @property {string} memoryId
36
+ * @property {string} name
37
+ * @property {string} addedAt
38
+ */
39
+
40
+ /**
41
+ * Load state from disk. Returns an empty shell if the file is missing.
42
+ *
43
+ * @param {string} statePath
44
+ * @returns {State}
45
+ */
46
+ export function load(statePath) {
47
+ try {
48
+ const raw = fs.readFileSync(statePath, "utf8");
49
+ const parsed = JSON.parse(raw);
50
+ if (parsed && typeof parsed === "object") {
51
+ return {
52
+ version: parsed.version ?? STATE_VERSION,
53
+ updatedAt: parsed.updatedAt ?? new Date().toISOString(),
54
+ runs: Array.isArray(parsed.runs) ? parsed.runs : [],
55
+ agents: parsed.agents && typeof parsed.agents === "object" ? parsed.agents : {},
56
+ };
57
+ }
58
+ } catch {
59
+ /* fall through to empty */
60
+ }
61
+ return empty();
62
+ }
63
+
64
+ /**
65
+ * @returns {State}
66
+ */
67
+ export function empty() {
68
+ return {
69
+ version: STATE_VERSION,
70
+ updatedAt: new Date().toISOString(),
71
+ runs: [],
72
+ agents: {},
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Atomic write: writes to `${statePath}.tmp` then renames.
78
+ *
79
+ * @param {string} statePath
80
+ * @param {State} state
81
+ */
82
+ export function save(statePath, state) {
83
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
84
+ state.updatedAt = new Date().toISOString();
85
+ const tmp = `${statePath}.tmp.${process.pid}.${Date.now()}`;
86
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
87
+ fs.renameSync(tmp, statePath);
88
+ }
89
+
90
+ /**
91
+ * Ensure an agent bucket exists and return it (mutable reference).
92
+ *
93
+ * @param {State} state
94
+ * @param {string} agentId
95
+ * @returns {AgentState}
96
+ */
97
+ export function ensureAgent(state, agentId) {
98
+ if (!state.agents[agentId]) {
99
+ state.agents[agentId] = {
100
+ memoryConversationId: null,
101
+ conversations: {},
102
+ memories: {},
103
+ };
104
+ }
105
+ return state.agents[agentId];
106
+ }
107
+
108
+ /**
109
+ * Has this session already been fully ingested?
110
+ *
111
+ * @param {State} state
112
+ * @param {string} agentId
113
+ * @param {string} sessionUuid
114
+ * @returns {boolean}
115
+ */
116
+ export function isSessionDone(state, agentId, sessionUuid) {
117
+ return state.agents[agentId]?.conversations?.[sessionUuid]?.status === "done";
118
+ }
119
+
120
+ /**
121
+ * Return the stored conversation record for (agentId, sessionUuid), or null.
122
+ * Callers use this to resume a `partial` ingestion.
123
+ *
124
+ * @param {State} state
125
+ * @param {string} agentId
126
+ * @param {string} sessionUuid
127
+ * @returns {ConversationState | null}
128
+ */
129
+ export function getSession(state, agentId, sessionUuid) {
130
+ return state.agents[agentId]?.conversations?.[sessionUuid] ?? null;
131
+ }
132
+
133
+ /**
134
+ * Upsert a session record. Returns the mutated record.
135
+ *
136
+ * @param {State} state
137
+ * @param {string} agentId
138
+ * @param {string} sessionUuid
139
+ * @param {Partial<ConversationState>} patch
140
+ * @returns {ConversationState}
141
+ */
142
+ export function markSession(state, agentId, sessionUuid, patch) {
143
+ const agent = ensureAgent(state, agentId);
144
+ const prev = agent.conversations[sessionUuid] ?? {
145
+ conversationId: "",
146
+ status: "pending",
147
+ messagesSent: 0,
148
+ memoryPairsSent: 0,
149
+ lastUuid: null,
150
+ completedAt: null,
151
+ lastError: null,
152
+ };
153
+ const next = { ...prev, ...patch };
154
+ agent.conversations[sessionUuid] = next;
155
+ return next;
156
+ }
157
+
158
+ /**
159
+ * @param {State} state
160
+ * @param {string} agentId
161
+ * @param {string} hash
162
+ * @returns {boolean}
163
+ */
164
+ export function isMemoryDone(state, agentId, hash) {
165
+ return Boolean(state.agents[agentId]?.memories?.[hash]);
166
+ }
167
+
168
+ /**
169
+ * @param {State} state
170
+ * @param {string} agentId
171
+ * @param {string} hash
172
+ * @param {MemoryState} record
173
+ */
174
+ export function markMemory(state, agentId, hash, record) {
175
+ const agent = ensureAgent(state, agentId);
176
+ agent.memories[hash] = record;
177
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * migrate.mjs — CLI-facing entry points for the `migrate` subcommand.
3
+ *
4
+ * Three externally-visible actions:
5
+ * - startMigration(...) — foreground wrapper that forks a detached child
6
+ * - readStatus(...) — print latest run's progress
7
+ * - stopMigration(...) — SIGTERM the running child, wait briefly, SIGKILL
8
+ *
9
+ * The detached child re-enters this module via `runChildMigration()`
10
+ * (invoked from bin/create-meko-setup.mjs when --__migrate-child is set).
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { runMigration } from "./migrate/orchestrator.mjs";
16
+ import { createLogger } from "./migrate/logger.mjs";
17
+ import { generateRunId, forkDetached, migrateRoot } from "./migrate/daemon.mjs";
18
+ import * as lockfile from "./migrate/lockfile.mjs";
19
+ import * as progress from "./migrate/progress.mjs";
20
+
21
+ function home() {
22
+ return process.env.HOME || "";
23
+ }
24
+
25
+ function paths(opts = {}) {
26
+ const root = migrateRoot({ home: opts.home ?? home() });
27
+ return {
28
+ root,
29
+ statePath: path.join(root, "state.json"),
30
+ lockPath: path.join(root, "lock"),
31
+ runDir: (runId) => path.join(root, runId),
32
+ logFile: (runId) => path.join(root, runId, "stdout.log"),
33
+ progressFile: (runId) => path.join(root, runId, "progress.json"),
34
+ };
35
+ }
36
+
37
+ function findLatestRunId(root) {
38
+ try {
39
+ const dirs = fs.readdirSync(root, { withFileTypes: true })
40
+ .filter((d) => d.isDirectory())
41
+ .map((d) => d.name)
42
+ .filter((n) => /^\d{8}-\d{6}-[0-9a-f]{6}$/.test(n))
43
+ .sort();
44
+ return dirs.at(-1) ?? null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Foreground wrapper: acquire lock, fork detached child, print run-id.
52
+ *
53
+ * @param {object} opts
54
+ * @param {string} opts.url
55
+ * @param {string|null} opts.apiKey
56
+ * @param {"user"|"project"} opts.scope
57
+ * @param {string} opts.projectRoot
58
+ * @param {string} opts.scriptPath Absolute path to create-meko-setup.mjs.
59
+ * @param {boolean} [opts.dryRun]
60
+ * @param {boolean} [opts.noExtract]
61
+ * @param {boolean} [opts.force]
62
+ * @param {number} [opts.rate]
63
+ * @returns {Promise<{ok: boolean, runId?: string, message: string}>}
64
+ */
65
+ export async function startMigration(opts) {
66
+ const P = paths();
67
+ fs.mkdirSync(P.root, { recursive: true });
68
+
69
+ // Dry-run is a local-only planning pass: no HTTP, no lock, no fork.
70
+ // Don't gate it on URL/apiKey.
71
+ if (opts.dryRun) {
72
+ const runId = generateRunId();
73
+ const runDir = P.runDir(runId);
74
+ const summary = await runMigration({
75
+ url: opts.url ?? "",
76
+ apiKey: opts.apiKey ?? null,
77
+ runId,
78
+ scope: opts.scope,
79
+ projectCwd: opts.projectRoot,
80
+ runDir,
81
+ statePath: P.statePath,
82
+ lockPath: P.lockPath,
83
+ dryRun: true,
84
+ noExtract: opts.noExtract,
85
+ concurrency: opts.rate,
86
+ });
87
+ const s = summary.summary;
88
+ const planned = s.plannedCalls ?? {};
89
+ return {
90
+ ok: summary.ok,
91
+ runId,
92
+ message:
93
+ (opts.url ? "" : "(no --url provided — planning only)\n") +
94
+ `Dry run:\n` +
95
+ ` Claude Code sessions: ${s.claudeSessions}\n` +
96
+ ` Cursor sessions: ${s.cursorSessions}\n` +
97
+ ` Paired user↔asst turns: ${s.pairedTurns}\n` +
98
+ ` Memory items (MD): ${s.memoryItems}\n` +
99
+ ` Planned MCP calls:\n` +
100
+ ` conversation_create: ${planned.conversation_create ?? 0}\n` +
101
+ ` conversation_add_message: ${planned.conversation_add_message ?? 0}\n` +
102
+ ` memory_add (messages=): ${planned.memory_add_messages ?? 0}\n` +
103
+ ` memory_add (text=): ${planned.memory_add_text ?? 0}\n` +
104
+ ` total: ${planned.total ?? 0}`,
105
+ };
106
+ }
107
+
108
+ // Real run — now URL/apiKey are required (dry-run branch already returned).
109
+ if (!opts.url) {
110
+ return { ok: false, message: "--url (or MEKO_MCP_URL) is required for --migrate." };
111
+ }
112
+ if (!opts.apiKey && !isLocalUrl(opts.url)) {
113
+ return { ok: false, message: "--api-key (or MEKO_API_KEY) is required for cloud --migrate." };
114
+ }
115
+
116
+ // Reserve the lock slot in the foreground so a concurrent --migrate fails
117
+ // fast. We use a two-step dance: first, O_EXCL-create the lock with the
118
+ // foreground PID (just to claim the slot), then — after spawning the
119
+ // child — rewrite it in place with the child's PID. The orchestrator in
120
+ // the child is told the lock is already held so it doesn't try to
121
+ // re-acquire (which would self-deadlock, since it would see its own PID
122
+ // as a live holder).
123
+ const runId = generateRunId();
124
+ const acq = lockfile.acquire(P.lockPath, { runId }, { force: opts.force });
125
+ if (!acq.acquired) {
126
+ return {
127
+ ok: false,
128
+ message:
129
+ `Another migration is running (pid=${acq.holder?.pid}, run-id=${acq.holder?.runId}). ` +
130
+ `Use --migrate-status, --migrate-stop, or --force to override.`,
131
+ };
132
+ }
133
+
134
+ fs.mkdirSync(P.runDir(runId), { recursive: true });
135
+ fs.writeFileSync(
136
+ path.join(P.runDir(runId), "cmd.json"),
137
+ JSON.stringify(
138
+ {
139
+ runId,
140
+ scope: opts.scope,
141
+ url: opts.url,
142
+ apiKeyPresent: !!opts.apiKey,
143
+ projectRoot: opts.projectRoot,
144
+ noExtract: !!opts.noExtract,
145
+ force: !!opts.force,
146
+ rate: opts.rate ?? null,
147
+ startedAt: new Date().toISOString(),
148
+ },
149
+ null,
150
+ 2,
151
+ ),
152
+ );
153
+
154
+ const passthrough = ["--scope", opts.scope, "--url", opts.url];
155
+ if (opts.noExtract) passthrough.push("--no-extract");
156
+ if (opts.force) passthrough.push("--force");
157
+ if (opts.rate) passthrough.push("--rate", String(opts.rate));
158
+
159
+ const env = { ...process.env };
160
+ if (opts.apiKey) env.MEKO_API_KEY = opts.apiKey;
161
+
162
+ const pid = forkDetached({
163
+ scriptPath: opts.scriptPath,
164
+ runId,
165
+ args: passthrough,
166
+ logFilePath: P.logFile(runId),
167
+ env,
168
+ cwd: opts.projectRoot,
169
+ });
170
+
171
+ // Hand lock ownership to the child. The rewrite is the same file that
172
+ // was O_EXCL-created above, so no race with a competing --migrate.
173
+ try {
174
+ fs.writeFileSync(
175
+ P.lockPath,
176
+ JSON.stringify({ pid, runId, startedAt: new Date().toISOString() }),
177
+ );
178
+ } catch { /* best effort */ }
179
+
180
+ return {
181
+ ok: true,
182
+ runId,
183
+ message:
184
+ `Migration started. run-id=${runId}, pid=${pid}\n` +
185
+ `Logs: ${P.logFile(runId)}\n` +
186
+ `Status: create-meko-setup --migrate-status`,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Invoked by the detached child via bin/create-meko-setup.mjs.
192
+ *
193
+ * The foreground wrapper has already taken the lock and written the
194
+ * child's PID into it; we MUST NOT re-acquire (that would self-deadlock
195
+ * because lockfile.isStale(our-own-pid) returns false).
196
+ *
197
+ * @param {object} opts
198
+ * @param {string} opts.runId
199
+ * @param {"user"|"project"} opts.scope
200
+ * @param {string} opts.url
201
+ * @param {string|null} opts.apiKey
202
+ * @param {string} opts.projectRoot
203
+ * @param {boolean} [opts.noExtract]
204
+ * @param {boolean} [opts.force]
205
+ * @param {number} [opts.rate]
206
+ * @returns {Promise<number>} Exit code.
207
+ */
208
+ export async function runChildMigration(opts) {
209
+ const P = paths();
210
+ const runDir = P.runDir(opts.runId);
211
+ fs.mkdirSync(runDir, { recursive: true });
212
+
213
+ const log = createLogger(path.join(runDir, "log.ndjson"), { runId: opts.runId });
214
+
215
+ let shuttingDown = false;
216
+ const onSignal = (sig) => {
217
+ if (shuttingDown) return;
218
+ shuttingDown = true;
219
+ log.warn("interrupted", { signal: sig });
220
+ try { lockfile.release(P.lockPath); } catch { /* ignore */ }
221
+ log.close();
222
+ process.exit(130);
223
+ };
224
+ process.on("SIGTERM", () => onSignal("SIGTERM"));
225
+ process.on("SIGINT", () => onSignal("SIGINT"));
226
+
227
+ try {
228
+ const result = await runMigration({
229
+ url: opts.url,
230
+ apiKey: opts.apiKey,
231
+ runId: opts.runId,
232
+ scope: opts.scope,
233
+ projectCwd: opts.projectRoot,
234
+ runDir,
235
+ statePath: P.statePath,
236
+ lockPath: P.lockPath,
237
+ noExtract: opts.noExtract,
238
+ force: opts.force,
239
+ lockAlreadyHeld: true,
240
+ concurrency: opts.rate,
241
+ });
242
+ log.info("child_done", result.summary);
243
+ log.close();
244
+ try { lockfile.release(P.lockPath); } catch { /* ignore */ }
245
+ return result.ok ? 0 : 1;
246
+ } catch (err) {
247
+ log.error("child_crashed", { error: err.message, stack: err.stack });
248
+ log.close();
249
+ try { lockfile.release(P.lockPath); } catch { /* ignore */ }
250
+ return 1;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Print and return the status of the latest (or requested) migration run.
256
+ *
257
+ * @param {object} opts
258
+ * @param {string} [opts.runId]
259
+ * @returns {{ok: boolean, runId: string|null, progress: object|null, message: string}}
260
+ */
261
+ export function readStatus(opts = {}) {
262
+ const P = paths();
263
+ const runId = opts.runId ?? findLatestRunId(P.root);
264
+ if (!runId) {
265
+ return { ok: false, runId: null, progress: null, message: "No migration runs found." };
266
+ }
267
+ const prog = progress.read(P.progressFile(runId));
268
+ if (!prog) {
269
+ return { ok: false, runId, progress: null, message: `No progress file for run-id=${runId}.` };
270
+ }
271
+ const lines = [
272
+ `run-id: ${prog.runId}`,
273
+ `status: ${prog.status}`,
274
+ `scope: ${prog.scope}`,
275
+ `started: ${prog.startedAt}`,
276
+ `finished: ${prog.finishedAt ?? "(in progress)"}`,
277
+ `current: ${prog.currentAgent ?? "-"} / ${prog.currentSession ?? "-"}`,
278
+ `sessions: ${prog.counts.sessionsDone} / ${prog.counts.sessionsPlanned}`,
279
+ `messages: ${prog.counts.messagesSent}`,
280
+ `memory pairs: ${prog.counts.memoryPairsSent}`,
281
+ `memory (MD): ${prog.counts.memoriesFromMD}`,
282
+ `warnings: ${prog.counts.warnings}`,
283
+ `memory errs: ${prog.counts.memoryErrors}`,
284
+ ];
285
+ return { ok: true, runId, progress: prog, message: lines.join("\n") };
286
+ }
287
+
288
+ /**
289
+ * SIGTERM the holder, wait briefly, SIGKILL. Best-effort.
290
+ *
291
+ * @returns {{ok: boolean, message: string}}
292
+ */
293
+ export async function stopMigration() {
294
+ const P = paths();
295
+ const holder = lockfile.read(P.lockPath);
296
+ if (!holder) return { ok: false, message: "No migration lock found." };
297
+ if (lockfile.isStale(holder)) {
298
+ try { lockfile.release(P.lockPath); } catch { /* ignore */ }
299
+ return { ok: true, message: `Stale lock removed (pid=${holder.pid}).` };
300
+ }
301
+ try {
302
+ process.kill(holder.pid, "SIGTERM");
303
+ } catch (err) {
304
+ return { ok: false, message: `Failed to signal pid=${holder.pid}: ${err.message}` };
305
+ }
306
+
307
+ // Wait up to 2s.
308
+ const deadline = Date.now() + 2000;
309
+ while (Date.now() < deadline) {
310
+ await new Promise((r) => setTimeout(r, 100));
311
+ if (lockfile.isStale(lockfile.read(P.lockPath) ?? holder)) {
312
+ return { ok: true, message: `Stopped pid=${holder.pid} (run-id=${holder.runId}).` };
313
+ }
314
+ }
315
+
316
+ try {
317
+ process.kill(holder.pid, "SIGKILL");
318
+ } catch { /* ignore */ }
319
+ try { lockfile.release(P.lockPath); } catch { /* ignore */ }
320
+ return {
321
+ ok: true,
322
+ message: `Force-killed pid=${holder.pid} (run-id=${holder.runId}).`,
323
+ };
324
+ }
325
+
326
+ function isLocalUrl(url) {
327
+ return /localhost|127\.0\.0\.1/.test(url ?? "");
328
+ }