@coulb/crux-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/lib/cancel.js ADDED
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cancel tombstones: the one thing that can tell a supervisor "this worker's silence was on purpose".
5
+ *
6
+ * The supervisor's contract is that silence is the bug — a headless worker whose process ends having
7
+ * printed nothing gets "**Worker failed** … it crashed, was killed, or never started" on the thread
8
+ * and a push to the human's phone {@see ../lib/spawn workerReport}. That is right, and it is not
9
+ * being weakened here. The problem is that a lead stopping a worker ON PURPOSE — the plan changed
10
+ * mid-flight, the brief is now wrong, `solo processes stop` — produces *the identical observation*:
11
+ * process gone, buffer empty. From the supervisor's side a deliberate kill and a crash are the same
12
+ * event. So the supervisor cried wolf, and a crash note that cries wolf is worse than no crash note,
13
+ * because the real ones start getting ignored.
14
+ *
15
+ * The missing piece is not a smarter supervisor. It is a way for the killer to leave a note. So
16
+ * `crux cancel` writes a TOMBSTONE before it stops the process, and the supervisor — which is
17
+ * already awake and polling — finds it when the process ends and reports a cancellation instead of a
18
+ * death. Three things make that safe:
19
+ *
20
+ * ORDER. The tombstone is written BEFORE the kill. Written after, it would race a supervisor that
21
+ * polls every 5s, and lose often enough to report a crash anyway.
22
+ *
23
+ * KEY. `<item>:<solo-process>`, not `<item>`. The case that makes this mandatory is real: thread
24
+ * #74 was cancelled and RE-SPAWNED onto a new Solo process minutes later. An item-keyed tombstone
25
+ * left behind by the first worker would have silenced the *second* one's genuine crash. A
26
+ * tombstone may only ever excuse the exact process it was written for.
27
+ *
28
+ * TTL. Solo process ids are not promised to be unique forever, and a tombstone whose supervisor
29
+ * never came (it died; the kill never landed) would otherwise sit there for good. One that is
30
+ * older than {@see TOMBSTONE_TTL_MS} is ignored on sight and pruned on the next write, so the
31
+ * worst a stale tombstone can do is nothing.
32
+ *
33
+ * Consumption is exactly-once: {@see takeTombstone} deletes under the same lock that reads. A second
34
+ * supervisor on the same process finds nothing, which is the correct answer — the cancellation has
35
+ * already been reported, and re-reporting it would be a second lie in the other direction.
36
+ */
37
+
38
+ const os = require('os');
39
+ const path = require('path');
40
+ const { readJson, updateJson } = require('./store');
41
+
42
+ /** Beside the tail cursor, and overridable for the same reason: tests must not touch the real fleet's. */
43
+ const CANCEL_FILE =
44
+ process.env.CRUX_CANCEL_FILE || path.join(os.homedir(), '.config', 'crux', 'cancelled.json');
45
+
46
+ /**
47
+ * How long a tombstone may excuse a worker's silence.
48
+ *
49
+ * Generously longer than the seconds a supervisor actually needs (it polls every 5s and the kill has
50
+ * already landed), because expiring one too early would resurrect the very bug this kills. But finite,
51
+ * because an unconsumed tombstone is a loaded gun pointed at whatever process id comes round again.
52
+ */
53
+ const TOMBSTONE_TTL_MS = 24 * 60 * 60 * 1000;
54
+
55
+ /** A tombstone excuses ONE worker: this item, on this Solo process. Never an item alone. */
56
+ function tombstoneKey(item, procId) {
57
+ return `${item}:${procId}`;
58
+ }
59
+
60
+ /** Unparseable timestamps count as expired — a corrupt record must not be able to silence anything. */
61
+ function expired(record, now) {
62
+ const at = Date.parse(record?.at ?? '');
63
+ return !Number.isFinite(at) || now - at > TOMBSTONE_TTL_MS;
64
+ }
65
+
66
+ function prune(map, now) {
67
+ for (const [key, record] of Object.entries(map)) {
68
+ if (expired(record, now)) delete map[key];
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Record that this worker is about to be stopped on purpose. Called BEFORE the kill — see the header.
74
+ *
75
+ * Throws if it cannot write, and `crux cancel` lets that be fatal: a cancel whose tombstone did not
76
+ * land is a cancel that will be reported as a crash, so it is better to stop before killing anything
77
+ * than to kill the worker and then lie about why it died.
78
+ */
79
+ function writeTombstone({ item, process: procId, note = null, actor = null, now = Date.now() }) {
80
+ const key = tombstoneKey(item, procId);
81
+
82
+ updateJson(CANCEL_FILE, (map) => {
83
+ prune(map, now);
84
+ map[key] = {
85
+ item: Number(item),
86
+ process: String(procId),
87
+ note: typeof note === 'string' && note.trim() !== '' ? note.trim() : null,
88
+ actor: typeof actor === 'string' && actor.trim() !== '' ? actor.trim() : null,
89
+ at: new Date(now).toISOString(),
90
+ };
91
+ });
92
+
93
+ return key;
94
+ }
95
+
96
+ /**
97
+ * The tombstone for this exact worker, consumed — or null if its silence was never explained.
98
+ *
99
+ * Null is the load-bearing return: it means CRASH, and the supervisor must go on reporting a crash
100
+ * loudly. Every failure here (no file, unreadable file, a lock we could not take) therefore returns
101
+ * null rather than throwing, which fails toward the noisy answer. A supervisor that cannot read the
102
+ * tombstones must still be able to say a worker died.
103
+ *
104
+ * The pre-check is unlocked on purpose: the overwhelmingly common call is a worker that finished
105
+ * normally, and there is nothing to consume. Reading first means that path never takes the lock and
106
+ * never creates the file. It cannot race, because the tombstone is durably written before the kill
107
+ * that produces the very process-death this is called in response to.
108
+ */
109
+ function takeTombstone(item, procId, now = Date.now()) {
110
+ const key = tombstoneKey(item, procId);
111
+ if (readJson(CANCEL_FILE)[key] === undefined) return null;
112
+
113
+ try {
114
+ return updateJson(CANCEL_FILE, (map) => {
115
+ const found = map[key];
116
+ delete map[key];
117
+ prune(map, now);
118
+ return found !== undefined && !expired(found, now) ? found : null;
119
+ });
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ module.exports = { CANCEL_FILE, TOMBSTONE_TTL_MS, tombstoneKey, writeTombstone, takeTombstone };
package/lib/cursor.js ADDED
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The durable tail cursor: one JSON map, shared by every `crux tail` on the machine.
5
+ *
6
+ * Write-then-rename already made each *write* atomic. It did not make the *read-modify-write*
7
+ * safe, and seven leads share this file: each reads the whole map, edits its own key, and writes
8
+ * it all back. Two tails interleaving that sequence lose the other's advance — last writer wins
9
+ * and the loser's cursor rolls backwards. Delivery is at-least-once, so the consequence is a
10
+ * replay rather than data loss, but a lead reprocessing an item is exactly the double-processing
11
+ * the fleet is trying to avoid. So the whole sequence is serialised under a lock {@see ./store}.
12
+ */
13
+
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const { LOCK_STALE_MS, withFileLock, readJson, updateJson } = require('./store');
17
+
18
+ /** Overridable so the concurrency test does not fight the real fleet's cursor file. */
19
+ const CURSOR_FILE = process.env.CRUX_CURSOR_FILE || path.join(os.homedir(), '.config', 'crux', 'tail-cursor.json');
20
+ const CURSOR_LOCK = `${CURSOR_FILE}.lock`;
21
+
22
+ /**
23
+ * Cursors are keyed by exactly what identifies a stream — including the server, since a cursor is
24
+ * a position in *that* server's ledger. A `--unrouted` tail, a `--project crux` tail, and a tail
25
+ * against a local dev API all keep their own position in the one file.
26
+ */
27
+ function cursorKey({ url, topics, project, status }) {
28
+ return `${url}|topics=${topics}|project=${project || ''}|status=${status || ''}`;
29
+ }
30
+
31
+ function readCursors() {
32
+ return readJson(CURSOR_FILE);
33
+ }
34
+
35
+ function withCursorLock(fn) {
36
+ return withFileLock(CURSOR_FILE, fn);
37
+ }
38
+
39
+ /** Advance one stream's cursor without clobbering any other's. */
40
+ function saveCursor(key, cursor) {
41
+ try {
42
+ updateJson(CURSOR_FILE, (cursors) => {
43
+ cursors[key] = cursor;
44
+ });
45
+ } catch (e) {
46
+ // A tail that cannot persist its cursor still works; it just replays on restart.
47
+ console.error(`crux tail: could not save cursor: ${e.message}`);
48
+ }
49
+ }
50
+
51
+ module.exports = { CURSOR_FILE, CURSOR_LOCK, LOCK_STALE_MS, cursorKey, readCursors, saveCursor, withCursorLock };
@@ -0,0 +1,422 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `crux discover` — step zero. Point it at a repo, answer a few questions, and you have a crux
5
+ * project: registered with the API, and declared in the local swarm config so `crux swarm up` knows
6
+ * where its lead runs.
7
+ *
8
+ * Everything in this file is PURE: no processes, no network, no prompts, no clock. It decides what
9
+ * SHOULD be written; `crux.js` reads the disk, asks the questions, and does the writes. That split is
10
+ * what makes the refusals testable, and — exactly as in `lib/swarm.js` — the refusals are the product.
11
+ *
12
+ * ## The rule the whole file is organised around
13
+ *
14
+ * **Do not ask what you can read.** A repo already knows what it is called (its remote, its
15
+ * `package.json`, its directory), what it is (its manifest), and roughly what it is about (its
16
+ * README). Every one of those is a question a stranger should never be asked, because being asked it
17
+ * is what makes a tool feel like a form. `crux discover` + Enter + Enter + Enter is the common case,
18
+ * and `--yes` is the same run with the Enters implied.
19
+ *
20
+ * ## And the rule that keeps it honest
21
+ *
22
+ * A second run must not create a second project, and it must not report success while doing nothing.
23
+ * {@see planWrite} therefore returns TWO independent verdicts — one for the API project, one for the
24
+ * local config entry — because the interesting states are the ones where those disagree: a project
25
+ * that exists in the feed but has no repo on this machine (the router creates projects; it cannot
26
+ * know where their code lives) is a real, useful thing for discover to do, and reporting it as
27
+ * "already a crux project, nothing to do" would be a lie of exactly the shape we removed from
28
+ * `crux accept` tonight.
29
+ */
30
+
31
+ const path = require('path');
32
+
33
+ /**
34
+ * The colours a project may be.
35
+ *
36
+ * Deliberately the INTERSECTION of what the two surfaces render, not the union. The app's
37
+ * `projectHues` (app/src/theme.ts) knows twenty Tailwind hue names; the web's project chip
38
+ * (`components/project-chip.blade.php`) special-cases six and falls through to zinc for everything
39
+ * else. So a project set to `teal` is coloured on your phone and grey on the web — a difference
40
+ * nobody would connect back to the moment they typed "teal" at a prompt. Offer only what is true on
41
+ * both, and refuse the rest by name rather than accept it and quietly grey it out.
42
+ */
43
+ const PALETTE = ['emerald', 'violet', 'sky', 'amber', 'rose', 'pink', 'zinc'];
44
+
45
+ /** Words that survive in a keyword list but carry no routing signal. */
46
+ const STOPWORDS = new Set([
47
+ 'the', 'a', 'an', 'and', 'or', 'of', 'for', 'to', 'my', 'our', 'new',
48
+ 'app', 'application', 'project', 'repo', 'repository', 'src', 'code',
49
+ 'main', 'master', 'dev', 'test', 'demo', 'com', 'io', 'git',
50
+ ]);
51
+
52
+ /** How many keywords the router gets. Beyond this they stop discriminating and start colliding. */
53
+ const MAX_KEYWORDS = 8;
54
+
55
+ /** `Acme Importer` / `@acme/importer` / `acme_importer` → `acme-importer`. */
56
+ function slugify(text) {
57
+ return String(text ?? '')
58
+ .normalize('NFKD')
59
+ .replace(/[\u0300-\u036f]/g, '')
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9]+/g, '-')
62
+ .replace(/^-+|-+$/g, '')
63
+ .slice(0, 60);
64
+ }
65
+
66
+ /**
67
+ * The project name out of a git remote, an npm/composer name, or a directory.
68
+ *
69
+ * `git@github.com:acme/orbit.git`, `https://github.com/acme/orbit`, `@acme/orbit`, `acme/orbit` —
70
+ * every one of them is the project "orbit", and none of them is a question worth asking a human.
71
+ * Returns null rather than guess at something that is not a remote.
72
+ */
73
+ function repoFromRemote(url) {
74
+ if (typeof url !== 'string' || url.trim() === '') return null;
75
+ const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, '');
76
+ const last = cleaned.split(/[/:]/).filter(Boolean).pop();
77
+ return last && !/^(https?|ssh|git)$/i.test(last) ? last : null;
78
+ }
79
+
80
+ /** The owner/org in a remote (`acme` in `github.com:acme/orbit`), which is a keyword worth having. */
81
+ function ownerFromRemote(url) {
82
+ if (typeof url !== 'string' || url.trim() === '') return null;
83
+ const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, '');
84
+ const parts = cleaned.split(/[/:]/).filter(Boolean);
85
+ const owner = parts.at(-2);
86
+ return owner && !/^(https?|ssh|git|github\.com|gitlab\.com|bitbucket\.org)$/i.test(owner) ? owner : null;
87
+ }
88
+
89
+ /** `@acme/orbit` → `orbit`; `acme/orbit` (composer) → `orbit`. A scope is a vendor, not a name. */
90
+ function unscope(name) {
91
+ if (typeof name !== 'string' || name === '') return null;
92
+ const bare = name.replace(/^@/, '').split('/').pop();
93
+ return bare || null;
94
+ }
95
+
96
+ /**
97
+ * What the project is CALLED, in the order a human would guess it.
98
+ *
99
+ * The remote comes first because it is the name the rest of the world uses for this code — a
100
+ * directory can be `orbit-2`, a `package.json` can still say `orbit-old`, but `origin` is the name
101
+ * on the tin. The manifest is next (it is at least maintained), and the directory is the floor.
102
+ */
103
+ function deriveName({ remote, manifestName, dirName }) {
104
+ return repoFromRemote(remote) || unscope(manifestName) || dirName || null;
105
+ }
106
+
107
+ /** `acme-importer` → `Acme Importer`. A name is read by a person; the slug is the machine's copy. */
108
+ function titleCase(name) {
109
+ return String(name ?? '')
110
+ .split(/[\s\-_.]+/)
111
+ .filter(Boolean)
112
+ .map((word) => word[0].toUpperCase() + word.slice(1))
113
+ .join(' ');
114
+ }
115
+
116
+ /**
117
+ * The name as a HUMAN would write it — the project's label in the feed, the app and the web.
118
+ *
119
+ * A remote and a directory are both lowercase-with-dashes because that is what filesystems and URLs
120
+ * want, and shipping `orbit` as a display name next to `Crux` and `ACME` reads like a placeholder
121
+ * somebody forgot to fill in. So it is title-cased — unless the README says the name outright, in
122
+ * which case that wins: a repo whose heading reads `# ACME` has already told us its capitalisation,
123
+ * and no rule of ours is going to beat `ACME` → `Acme`.
124
+ */
125
+ function deriveDisplayName({ name, title, slug }) {
126
+ if (title && slugify(title) === slug) return title;
127
+ return titleCase(name) || name;
128
+ }
129
+
130
+ /**
131
+ * What KIND of thing this repo is — for the lead's brief, and for the router's keywords.
132
+ *
133
+ * This is not trivia. A lead that knows it is standing in a Laravel app writes a better brief for its
134
+ * workers, asks better questions, and stops guessing at the test command; and "laravel" in the
135
+ * keyword list is what routes a captured "the queue worker is dying" to this project rather than the
136
+ * Go service next door. Read the manifests — every one of them is a file that already exists.
137
+ *
138
+ * @param {object} files the manifests that were found, already parsed where they are JSON
139
+ * @returns {{key: string, label: string, keywords: string[]}|null} null when nothing identifies it
140
+ */
141
+ function detectStack(files = {}) {
142
+ const { composer, pkg, goMod, cargo, gemfile, python } = files;
143
+
144
+ if (composer) {
145
+ const deps = { ...(composer.require || {}), ...(composer['require-dev'] || {}) };
146
+ if (deps['laravel/framework']) return { key: 'laravel', label: 'a Laravel app', keywords: ['laravel', 'php'] };
147
+ if (deps['symfony/framework-bundle']) return { key: 'symfony', label: 'a Symfony app', keywords: ['symfony', 'php'] };
148
+ return { key: 'php', label: 'a PHP project', keywords: ['php'] };
149
+ }
150
+
151
+ if (pkg) {
152
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
153
+ if (deps.expo || deps['react-native']) {
154
+ return { key: 'react-native', label: 'a React Native app', keywords: ['react-native', 'mobile'] };
155
+ }
156
+ if (deps.next) return { key: 'next', label: 'a Next.js app', keywords: ['next', 'react', 'node'] };
157
+ if (deps.react) return { key: 'react', label: 'a React app', keywords: ['react', 'node'] };
158
+ if (deps.express || deps.fastify || deps.hono) {
159
+ return { key: 'node-service', label: 'a Node service', keywords: ['node', 'api'] };
160
+ }
161
+ // A `bin` is the difference between a library and something you TYPE, and it is the one thing a
162
+ // package.json states outright rather than implies.
163
+ if (pkg.bin) return { key: 'node-cli', label: 'a Node CLI', keywords: ['cli', 'node'] };
164
+ return { key: 'node', label: 'a Node project', keywords: ['node'] };
165
+ }
166
+
167
+ if (goMod) return { key: 'go', label: 'a Go service', keywords: ['go'] };
168
+ if (cargo) return { key: 'rust', label: 'a Rust project', keywords: ['rust'] };
169
+ if (gemfile) {
170
+ return /['"]rails['"]/.test(gemfile)
171
+ ? { key: 'rails', label: 'a Rails app', keywords: ['rails', 'ruby'] }
172
+ : { key: 'ruby', label: 'a Ruby project', keywords: ['ruby'] };
173
+ }
174
+ if (python) return { key: 'python', label: 'a Python project', keywords: ['python'] };
175
+
176
+ return null;
177
+ }
178
+
179
+ /** The `# Title` of a README — the one line of prose a repo reliably has. */
180
+ function readmeTitle(readme) {
181
+ if (typeof readme !== 'string') return null;
182
+ const match = /^#\s+(.+?)\s*$/m.exec(readme);
183
+ if (!match) return null;
184
+ // Strip the markdown a title picks up: `crux swarm`, **bold**, [links](…).
185
+ const title = match[1]
186
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
187
+ .replace(/[`*_]/g, '')
188
+ .trim();
189
+ return title || null;
190
+ }
191
+
192
+ /**
193
+ * The keywords the router will use to place a captured item into this project.
194
+ *
195
+ * These matter more than they look: they are the entire reason a thought typed into a phone at
196
+ * midnight ("the acme importer keeps timing out") lands on the right thread instead of the unrouted
197
+ * queue. Derived from every name this project already answers to — its slug, its title, its remote,
198
+ * its owner, and what it is built with — because those are exactly the words a human reaches for.
199
+ * Stopwords go, because "app" routes nothing.
200
+ */
201
+ function deriveKeywords({ slug, name, remote, stack, title }) {
202
+ const words = [
203
+ slug,
204
+ ...String(name ?? '').split(/[\s\-_/]+/),
205
+ repoFromRemote(remote),
206
+ ownerFromRemote(remote),
207
+ ...(stack ? stack.keywords : []),
208
+ ...String(title ?? '').split(/[\s\-_/]+/),
209
+ ];
210
+
211
+ const seen = new Set();
212
+ const keywords = [];
213
+ for (const raw of words) {
214
+ const word = slugify(raw);
215
+ if (!word || word.length < 2 || STOPWORDS.has(word) || seen.has(word)) continue;
216
+ seen.add(word);
217
+ keywords.push(word);
218
+ if (keywords.length === MAX_KEYWORDS) break;
219
+ }
220
+ return keywords;
221
+ }
222
+
223
+ /**
224
+ * A colour, without asking — but never the same one for everything.
225
+ *
226
+ * `zinc` is the API's default and it is grey; a fleet of grey projects is a fleet you cannot tell
227
+ * apart at a glance, which is the entire job of the colour. So the default is derived from the slug:
228
+ * stable (the same project is the same colour on every machine and every re-run, so `--yes` twice is
229
+ * genuinely idempotent), and spread across the palette. Zinc stays available — it is just never
230
+ * assigned by default.
231
+ */
232
+ function colorFor(slug) {
233
+ const hues = PALETTE.filter((c) => c !== 'zinc');
234
+ let hash = 0;
235
+ for (const char of String(slug ?? '')) hash = (hash * 31 + char.charCodeAt(0)) % 100_000;
236
+ return hues[hash % hues.length];
237
+ }
238
+
239
+ /** The one-line summary the router renders into its prompt, and the lead reads in its brief. */
240
+ function describe({ name, stack, title }) {
241
+ if (title && slugify(title) !== slugify(name)) return `${name} — ${title}`.slice(0, 255);
242
+ if (stack) return `${name} — ${stack.label}.`.slice(0, 255);
243
+ return `${name} — a git repo.`.slice(0, 255);
244
+ }
245
+
246
+ /** The fields the API stores. Compared, not just sent — see {@see planWrite}. */
247
+ function projectPayload({ slug, name, color, description, keywords }) {
248
+ return { slug, name, color, description, keywords };
249
+ }
250
+
251
+ /** Which of the API's fields this run would actually change. Empty means the POST is a no-op. */
252
+ function changedFields(existing, payload) {
253
+ if (!existing) return Object.keys(payload);
254
+
255
+ const changed = [];
256
+ for (const [key, value] of Object.entries(payload)) {
257
+ const before = existing[key] ?? null;
258
+ if (key === 'keywords') {
259
+ const a = JSON.stringify(before ?? []);
260
+ const b = JSON.stringify(value ?? []);
261
+ if (a !== b) changed.push(key);
262
+ continue;
263
+ }
264
+ if ((before ?? null) !== (value ?? null)) changed.push(key);
265
+ }
266
+ return changed;
267
+ }
268
+
269
+ /**
270
+ * Decide what this run writes, and what it refuses.
271
+ *
272
+ * Two verdicts, because there are two places and they can disagree — and every interesting state
273
+ * lives in the disagreement:
274
+ *
275
+ * project: create | update | unchanged the crux project, in the API
276
+ * config: add | unchanged `swarm.projects.<slug>.repo`, on this machine
277
+ *
278
+ * The pair is what lets the caller tell the truth afterwards. A project the router already created
279
+ * has no repo on this machine, so discover has real work to do (`config: add`) even though the
280
+ * project itself is `unchanged` — and a run that said "already a crux project, nothing to do" there
281
+ * would be the `crux accept` bug wearing a different hat: success reported, nothing done, and
282
+ * `crux swarm up` still unable to find the repo.
283
+ *
284
+ * Refusals name the fix, always. There are exactly two, and both are the same underlying mistake —
285
+ * a slug and a repo that do not agree about each other — caught from either side.
286
+ *
287
+ * @param {object} input
288
+ * @param {string} input.slug the chosen slug
289
+ * @param {string} input.repo the resolved repo path
290
+ * @param {object} input.payload what we would POST {@see projectPayload}
291
+ * @param {Array<object>} input.registry every project the API has (GET /api/projects)
292
+ * @param {Record<string, {repo?: string}>} input.configProjects the config's `swarm.projects`
293
+ * @param {(a: string, b: string) => boolean} [input.samePath] injected: realpath is I/O
294
+ */
295
+ function planWrite({ slug, repo, payload, registry, configProjects, samePath = (a, b) => a === b }) {
296
+ // This repo is already SOMEONE ELSE's project. Carrying on would give one directory two crux
297
+ // projects, two leads, and two streams — and the second lead would look, to everything downstream,
298
+ // exactly as legitimate as the first. This is the refusal that makes "run it twice" safe even when
299
+ // the second run answers the name prompt differently.
300
+ for (const [other, entry] of Object.entries(configProjects ?? {})) {
301
+ if (other === slug || typeof entry?.repo !== 'string') continue;
302
+ if (!samePath(entry.repo, repo)) continue;
303
+ return {
304
+ refusal:
305
+ `${repo} is already the crux project "${other}" (swarm.projects.${other}.repo in the config), ` +
306
+ `so discovering it as "${slug}" would give one repo two projects and two leads.\n` +
307
+ ` To edit that project instead: crux discover ${repo} --slug ${other}\n` +
308
+ ` To really move it: remove swarm.projects.${other} from the config first.`,
309
+ };
310
+ }
311
+
312
+ const declared = configProjects?.[slug]?.repo;
313
+
314
+ // The slug is taken, by a repo that is not this one. The project may be perfectly healthy — it is
315
+ // the NAME that collides — so the fix is a different slug, not a different repo.
316
+ if (typeof declared === 'string' && !samePath(declared, repo)) {
317
+ const existing = registry.find((p) => p.slug === slug);
318
+ return {
319
+ refusal:
320
+ `the slug "${slug}"${existing ? ` (the crux project "${existing.name}")` : ''} already has a ` +
321
+ `lead running in ${declared}, which is not ${repo}.\n` +
322
+ ` Pick another slug: crux discover ${repo} --slug <other-slug>\n` +
323
+ ` Or, if the project really moved, point swarm.projects.${slug}.repo at the new path.`,
324
+ };
325
+ }
326
+
327
+ const existing = registry.find((p) => p.slug === slug) ?? null;
328
+ const changed = changedFields(existing, payload);
329
+
330
+ return {
331
+ refusal: null,
332
+ existing,
333
+ changed,
334
+ project: !existing ? 'create' : changed.length ? 'update' : 'unchanged',
335
+ config: typeof declared !== 'string' ? 'add' : 'unchanged',
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Add (or correct) one project's repo in the leads config, in place, preserving everything else.
341
+ *
342
+ * The shape is not invented here — it is read back out by `swarmCandidates()` in crux.js as
343
+ * `config.swarm.projects[slug].repo`, tilde-expanded, and handed to the backend as the lead's
344
+ * working directory. The two are tested against each other (test/discover.test.js) precisely so they
345
+ * cannot drift: a config discover writes that swarm cannot read is a `swarm up` that says "no repo
346
+ * path for acme" about a repo the user just typed in.
347
+ *
348
+ * `brief` (extra lead context) is a sibling key a human may have hand-written, so it is preserved.
349
+ */
350
+ function mergeConfig(config, slug, repo) {
351
+ const next = { ...config };
352
+ next.swarm = { ...(next.swarm ?? {}) };
353
+ next.swarm.projects = { ...(next.swarm.projects ?? {}) };
354
+ next.swarm.projects[slug] = { ...(next.swarm.projects[slug] ?? {}), repo };
355
+ return next;
356
+ }
357
+
358
+ /**
359
+ * Write down the backend crux worked out for itself — and NOTHING the user already stated.
360
+ *
361
+ * `crux swarm up` on a fresh machine used to be a coin flip: the shipped config said `solo`, most
362
+ * strangers do not run Solo, and the ones who fell through to `exec` got "the exec backend needs a
363
+ * command template" one command after discover had cheerfully told them to run it. So discover now
364
+ * settles it — but settling it in memory would be no better than the old default. The value has to
365
+ * land in the file, where the user can READ what their leads are started with and change it.
366
+ *
367
+ * Every key here is one {@see ../lib/orchestrator.resolveBackend} has already established is ABSENT
368
+ * (it only ever reports what it had to derive), and the guard below is the second lock on the same
369
+ * door: a `swarm.backend` or a `swarm.exec.command` a human typed is theirs, and a "helpful" tool
370
+ * that rewrites it has taken their orchestrator away from them without asking.
371
+ */
372
+ function mergeBackend(config, writes) {
373
+ if (!writes || Object.keys(writes).length === 0) return config;
374
+
375
+ const next = { ...config };
376
+ next.swarm = { ...(next.swarm ?? {}) };
377
+ let touched = false;
378
+
379
+ if (typeof writes.backend === 'string' && typeof next.swarm.backend !== 'string') {
380
+ next.swarm.backend = writes.backend;
381
+ touched = true;
382
+ }
383
+
384
+ // The templates are a PAIR — `command` forks, `stop` is how you name what it forked into — so they
385
+ // are written together or not at all. Half a pair is the dead-pid refusal in `planDown`.
386
+ if (writes.exec && typeof next.swarm.exec?.command !== 'string') {
387
+ next.swarm.exec = { ...(next.swarm.exec ?? {}), ...writes.exec };
388
+ touched = true;
389
+ }
390
+
391
+ return touched ? next : config;
392
+ }
393
+
394
+ /** `/Users/you/src/acme` → `~/src/acme`, for output a human reads. */
395
+ function tildify(file, home) {
396
+ if (typeof file !== 'string' || typeof home !== 'string' || home === '') return file;
397
+ return file === home || file.startsWith(`${home}${path.sep}`) ? `~${file.slice(home.length)}` : file;
398
+ }
399
+
400
+ module.exports = {
401
+ MAX_KEYWORDS,
402
+ PALETTE,
403
+ STOPWORDS,
404
+ changedFields,
405
+ colorFor,
406
+ describe,
407
+ deriveDisplayName,
408
+ deriveKeywords,
409
+ deriveName,
410
+ detectStack,
411
+ titleCase,
412
+ mergeBackend,
413
+ mergeConfig,
414
+ ownerFromRemote,
415
+ planWrite,
416
+ projectPayload,
417
+ readmeTitle,
418
+ repoFromRemote,
419
+ slugify,
420
+ tildify,
421
+ unscope,
422
+ };