@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/LICENSE +21 -0
- package/README.md +405 -0
- package/crux.js +2697 -0
- package/leads.config.json +62 -0
- package/lib/backends.js +617 -0
- package/lib/cancel.js +125 -0
- package/lib/cursor.js +51 -0
- package/lib/discover.js +422 -0
- package/lib/leads.js +292 -0
- package/lib/orchestrator.js +237 -0
- package/lib/spawn.js +436 -0
- package/lib/store.js +119 -0
- package/lib/swarm-state.js +66 -0
- package/lib/swarm.js +628 -0
- package/package.json +40 -0
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 };
|
package/lib/discover.js
ADDED
|
@@ -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
|
+
};
|