clavarium 0.1.5 → 0.2.0-next.2
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/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/server/api.mjs +8 -0
- package/server/conversations.mjs +37 -4
- package/server/gitpush.mjs +63 -0
- package/server/main.mjs +4 -0
- package/server/names.mjs +31 -0
- package/server/toolbox.mjs +1 -1
- package/web/app.js +575 -246
- package/web/gitgraph.js +116 -0
- package/web/index.html +150 -135
- package/web/links.js +6 -2
- package/web/style.css +201 -4
- package/web/vendor/diff2html/LICENSE +49 -0
- package/web/vendor/diff2html/diff2html-ui-slim.min.js +1 -0
- package/web/vendor/diff2html/diff2html.min.css +1 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-next.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0-next.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
|
13
13
|
"@xterm/xterm": "^6.0.0",
|
|
14
|
-
"clavarium-parley": "0.
|
|
14
|
+
"clavarium-parley": "0.10.0-next.2",
|
|
15
15
|
"mermaid": "11.17.2",
|
|
16
16
|
"node-pty": "^1.1.0",
|
|
17
17
|
"pdfjs-dist": "6.4.299",
|
|
@@ -686,9 +686,9 @@
|
|
|
686
686
|
}
|
|
687
687
|
},
|
|
688
688
|
"node_modules/clavarium-parley": {
|
|
689
|
-
"version": "0.
|
|
690
|
-
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.
|
|
691
|
-
"integrity": "sha512
|
|
689
|
+
"version": "0.10.0-next.2",
|
|
690
|
+
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.2.tgz",
|
|
691
|
+
"integrity": "sha512-/6pAEMEKgug1FerqBKzqoMcWmnR6eYWtfiGt1Oa1gz9vWFuupxXlTeUFurukgfqLiEVOU6U+g1qJUff8PtTLBQ==",
|
|
692
692
|
"license": "MIT",
|
|
693
693
|
"bin": {
|
|
694
694
|
"parley": "bin/parley.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-next.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@xterm/addon-fit": "^0.11.0",
|
|
45
45
|
"@xterm/xterm": "^6.0.0",
|
|
46
|
-
"clavarium-parley": "0.
|
|
46
|
+
"clavarium-parley": "0.10.0-next.2",
|
|
47
47
|
"mermaid": "11.17.2",
|
|
48
48
|
"node-pty": "^1.1.0",
|
|
49
49
|
"pdfjs-dist": "6.4.299",
|
package/server/api.mjs
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* POST /api/updates/apply { version }: update to the version that is out (in the background)
|
|
38
38
|
* POST /api/updates/back { version }: go back to a release that is kept (in the background)
|
|
39
39
|
* GET /api/git/<name>/outgoing?target=<branch>&branch=<branch> what would go from the agent's branch, to that branch of the remote
|
|
40
|
+
* GET /api/git/<name>/graph the branch diagram: branches and their last commits
|
|
41
|
+
* GET /api/git/<name>/commit?id=<sha> one commit, as a review shows it
|
|
40
42
|
* POST /api/git/<name>/push {head, target?, sure?}: exactly that reviewed commit, to exactly that branch
|
|
41
43
|
* GET /api/git/<name>/keep origin kept up to date, checks, automatic sending, log
|
|
42
44
|
* POST /api/git/<name>/keep {refreshMinutes?, alert?, checkCommand?, checkSeconds?, runHook?, requireForSend?, fsck?}
|
|
@@ -215,6 +217,8 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
215
217
|
if (answered && answerer) return send(200, answerer.log(decodeURIComponent(answered[1]))), true;
|
|
216
218
|
/* A repository: where its commits go, and what would go now. */
|
|
217
219
|
const repo = /^\/api\/git\/([a-z0-9-]{1,32})(\/outgoing|\/keep)?$/.exec(pathname);
|
|
220
|
+
const drawn = /^\/api\/git\/([a-z0-9-]{1,32})\/(graph|commit)$/.exec(pathname);
|
|
221
|
+
if (drawn && gitReview) return send(200, drawn[2] === 'graph' ? await gitReview.graph(drawn[1]) : await gitReview.commit(drawn[1], query.get('id'))), true;
|
|
218
222
|
if (repo && repo[2] === '/keep' && gitKeeper) return send(200, gitKeeper.about(repo[1])), true;
|
|
219
223
|
if (repo && gitReview) return send(200, repo[2] ? await gitReview.outgoing(repo[1], { target: query.get('target') ?? undefined, branch: query.get('branch') ?? undefined }) : gitReview.settings(repo[1])), true;
|
|
220
224
|
const watched = /^\/api\/conversations\/([^/]+)\/signin$/.exec(pathname);
|
|
@@ -424,6 +428,10 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
424
428
|
if (asking) return send(200, conversations.setAskFirst(decodeURIComponent(asking[1]), body.on)), true;
|
|
425
429
|
const naming = /^\/api\/conversations\/([^/]+)\/chat-name$/.exec(pathname);
|
|
426
430
|
if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
|
|
431
|
+
const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
|
|
432
|
+
if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
|
|
433
|
+
const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
|
|
434
|
+
if (hiding) return send(200, conversations.setHidden(decodeURIComponent(hiding[1]), body.on)), true;
|
|
427
435
|
const marking = /^\/api\/conversations\/([^/]+)\/voice-mark$/.exec(pathname);
|
|
428
436
|
if (marking) return send(200, conversations.setVoiceMark(decodeURIComponent(marking[1]), body.on)), true;
|
|
429
437
|
const resuming = /^\/api\/conversations\/([^/]+)\/resume$/.exec(pathname);
|
package/server/conversations.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Sleep is a button, never a guess: telling a long job from an idle tool
|
|
7
7
|
* cannot be done without reading the tool, and this server does not read.
|
|
8
8
|
*/
|
|
9
|
-
import { isConversationName } from './names.mjs';
|
|
9
|
+
import { isConversationName, identifierFrom, titleProblem } from './names.mjs';
|
|
10
10
|
import { attachCommand, SOCKET } from './tmux.mjs';
|
|
11
11
|
import { WORK, VM_USER, SHARING_STEP } from './vm.mjs';
|
|
12
12
|
import { resumeOrFresh } from './tools.mjs';
|
|
@@ -189,7 +189,8 @@ export class Conversations {
|
|
|
189
189
|
|
|
190
190
|
/* Whether a new machine may be made for this request: what `create`
|
|
191
191
|
checks, and the server's room (capacity.mjs). Asked before `create`. */
|
|
192
|
-
async admit({ name, tool, memoryMb } = {}) {
|
|
192
|
+
async admit({ name, title, tool, memoryMb } = {}) {
|
|
193
|
+
if (!name && typeof title === 'string' && !titleProblem(title)) ({ name } = this.#naming({ title }));
|
|
193
194
|
const chosen = this.#tools.find((t) => t.id === tool);
|
|
194
195
|
/* What `create` refuses anyway is said by `create`. */
|
|
195
196
|
if (!chosen || !isConversationName(name) || this.#store.get(name)) return;
|
|
@@ -201,7 +202,17 @@ export class Conversations {
|
|
|
201
202
|
/* `workOnDisk`: the tool works on its machine's own disk instead of in a
|
|
202
203
|
folder kept by the server. Faster for a large code project; but what
|
|
203
204
|
it makes is then not seen from the page, and goes with the machine. */
|
|
204
|
-
|
|
205
|
+
/* A conversation is made from the name a person reads (`title`), its
|
|
206
|
+
identifier drawn from it, or from an identifier alone (the advanced
|
|
207
|
+
options, a blueprint). The identifier never changes afterwards. */
|
|
208
|
+
#naming({ name, title }) {
|
|
209
|
+
if (title !== undefined) { const problem = titleProblem(title); if (problem) throw new Refusal(400, problem); }
|
|
210
|
+
const id = name || (title !== undefined ? identifierFrom(title, (x) => Boolean(this.#store.get(x))) : name);
|
|
211
|
+
return { name: id, title: title !== undefined && title !== id ? title : undefined };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
create({ name: given, title: read, tool, memoryMb, workOnDisk }) {
|
|
215
|
+
const { name, title } = this.#naming({ name: given, title: read });
|
|
205
216
|
if (!isConversationName(name)) throw new Refusal(400, 'A name is 1 to 32 lower-case letters, digits or hyphens; it starts with a letter and does not end with a hyphen.');
|
|
206
217
|
const chosen = this.#tools.find((t) => t.id === tool);
|
|
207
218
|
if (!chosen) throw new Refusal(400, 'Unknown tool.');
|
|
@@ -211,7 +222,7 @@ export class Conversations {
|
|
|
211
222
|
}
|
|
212
223
|
if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
|
|
213
224
|
const now = new Date().toISOString();
|
|
214
|
-
const conversation = { name, tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
225
|
+
const conversation = { name, ...(title ? { title } : {}), tool, memoryMb: memory, diskGb: DISK_GB, fixed: FIXES, work: workOnDisk === true ? 'disk' : 'kept', phase: 'building', step: 'Starting the machine', createdAt: now, buildingSince: now, progress: buildProgress(now) };
|
|
215
226
|
this.#store.put(conversation);
|
|
216
227
|
this.built = this.#build(name, chosen, memory);
|
|
217
228
|
return conversation;
|
|
@@ -450,6 +461,28 @@ export class Conversations {
|
|
|
450
461
|
return this.#store.get(name);
|
|
451
462
|
}
|
|
452
463
|
|
|
464
|
+
/* THE NAME A PERSON READS, changed at any time; its identifier stays.
|
|
465
|
+
Kept only when it differs from the identifier: a conversation made
|
|
466
|
+
before free names is read by its identifier, and nothing is rewritten. */
|
|
467
|
+
setTitle(name, title) {
|
|
468
|
+
const c = this.#store.get(name);
|
|
469
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
470
|
+
const problem = titleProblem(title);
|
|
471
|
+
if (problem) throw new Refusal(400, problem);
|
|
472
|
+
this.#store.patch(name, { title: title === name ? undefined : title });
|
|
473
|
+
return this.#store.get(name);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* Hidden from the list, like a hidden file: it runs, keeps its rights,
|
|
477
|
+
and is still in Access; the list says how many are hidden. */
|
|
478
|
+
setHidden(name, on) {
|
|
479
|
+
const c = this.#store.get(name);
|
|
480
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
481
|
+
if (typeof on !== 'boolean') throw new Refusal(400, 'Say true (hidden from the list) or false (shown).');
|
|
482
|
+
this.#store.patch(name, { hidden: on ? true : undefined });
|
|
483
|
+
return this.#store.get(name);
|
|
484
|
+
}
|
|
485
|
+
|
|
453
486
|
/* What is dictated into its terminal starts with a microphone sign, once
|
|
454
487
|
per message (true, the default for a tool that is an AI), or not. */
|
|
455
488
|
setVoiceMark(name, on) {
|
package/server/gitpush.mjs
CHANGED
|
@@ -40,6 +40,9 @@ export const UNCHANGED_MAX = 50;
|
|
|
40
40
|
/* A commit message is shown up to this many characters, and the cut is said. */
|
|
41
41
|
export const MESSAGE_MAX = 4000;
|
|
42
42
|
export const REVIEWS_KEPT = 20;
|
|
43
|
+
/* The branch diagram: so many commits, so many of the remote's branches. */
|
|
44
|
+
export const GRAPH_COMMITS = 40;
|
|
45
|
+
export const GRAPH_BRANCHES = 20;
|
|
43
46
|
/* How long a review waits for the reference, and a send or a refresh. */
|
|
44
47
|
export const REVIEW_WAIT_MS = 10_000;
|
|
45
48
|
export const SEND_WAIT_MS = 60_000;
|
|
@@ -408,6 +411,66 @@ export class GitReview {
|
|
|
408
411
|
} finally { got.remove(); }
|
|
409
412
|
}
|
|
410
413
|
|
|
414
|
+
/* The workspace of one read, with the remote's history lent to it when
|
|
415
|
+
there is one, for `work(ws, remoteTips)`; deleted after. */
|
|
416
|
+
async #withRead(r, work) {
|
|
417
|
+
const got = await this.#read(r);
|
|
418
|
+
try {
|
|
419
|
+
let release = null; let remoteTips = new Map();
|
|
420
|
+
if (this.#remoteOf(r) && this.#resources.hasOrigin(r.name)) {
|
|
421
|
+
release = await this.#runner.lock(this.#reference(r.name), REVIEW_WAIT_MS);
|
|
422
|
+
this.#runner.borrow(got.path, this.#reference(r.name));
|
|
423
|
+
remoteTips = await this.#tipsIn(this.#reference(r.name));
|
|
424
|
+
}
|
|
425
|
+
try { return await work(got, remoteTips); } finally { release?.(); }
|
|
426
|
+
} finally { got.remove(); }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* THE BRANCH DIAGRAM: the agent's branches, the remote's, and their
|
|
430
|
+
last commits, newest first in topological order: { branches: [{name,
|
|
431
|
+
id, side: 'agent'|'remote'}], commits: [{id, parents, subject, date,
|
|
432
|
+
refs: [{name, side}]}] }. What the agent wrote is escaped as everywhere. */
|
|
433
|
+
async graph(name) {
|
|
434
|
+
const r = this.#repository(name);
|
|
435
|
+
try {
|
|
436
|
+
return await this.#withRead(r, async (got, remoteTips) => {
|
|
437
|
+
const branches = got.branches.map((b) => ({ name: escaped(b.name), id: b.id, side: 'agent' }));
|
|
438
|
+
for (const [b, id] of [...remoteTips].sort(([a], [c]) => a.localeCompare(c)).slice(0, GRAPH_BRANCHES)) branches.push({ name: escaped(b), id, side: 'remote' });
|
|
439
|
+
const tips = [...new Set(branches.map((b) => b.id))];
|
|
440
|
+
if (tips.length === 0) return { branches, commits: [] };
|
|
441
|
+
const log = await this.#runner.in(got.path, ['log', '--topo-order', `--max-count=${GRAPH_COMMITS}`, '-z', '--format=%H%x00%P%x00%aI%x00%s', '--stdin', '--end-of-options'], { input: `${tips.join('\n')}\n`, deadlineMs: 60_000, maxBytes: 4 * 1024 * 1024 });
|
|
442
|
+
const fields = log.stdout.toString('utf8').split('\0');
|
|
443
|
+
const commits = [];
|
|
444
|
+
for (let i = 0; i + 3 < fields.length; i += 4) {
|
|
445
|
+
const id = fields[i].replace(/^\n/, '');
|
|
446
|
+
if (!isId(id)) break;
|
|
447
|
+
commits.push({ id, parents: fields[i + 1].split(' ').filter(isId), date: fields[i + 2], subject: escaped(fields[i + 3]).slice(0, 300),
|
|
448
|
+
refs: branches.filter((b) => b.id === id).map((b) => ({ name: b.name, side: b.side })) });
|
|
449
|
+
}
|
|
450
|
+
return { branches, commits };
|
|
451
|
+
});
|
|
452
|
+
} catch (error) {
|
|
453
|
+
if (error instanceof GitFailure && error.kind === 'empty') return { branches: [], commits: [] };
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ONE COMMIT, as a review shows it: against its first parent (or
|
|
459
|
+
nothing, for the first commit). Never sent from here. */
|
|
460
|
+
async commit(name, id) {
|
|
461
|
+
const r = this.#repository(name);
|
|
462
|
+
const asked = String(id ?? '');
|
|
463
|
+
if (!isId(asked)) throw new Refusal(400, 'A commit is named by its full identifier.');
|
|
464
|
+
return this.#withRead(r, async (got) => {
|
|
465
|
+
const p = await this.#runner.in(got.path, ['rev-list', '--parents', '--max-count=1', '--end-of-options', asked], { deadlineMs: 60_000 });
|
|
466
|
+
const ids = p.stdout.toString('latin1').trim().split(' ');
|
|
467
|
+
if (p.code !== 0 || ids[0] !== asked) throw new Refusal(404, 'This commit is not in the repository.');
|
|
468
|
+
const parents = ids.slice(1).filter(isId);
|
|
469
|
+
const shown = await this.#compare(got.path, { head: asked, base: parents[0] ?? EMPTY_TREE, range: [asked], negatives: parents });
|
|
470
|
+
return { ...shown, id: asked, parents, head: asked, state: 'commit', message: parents.length > 1 ? 'A merge: its changes against its first parent.' : '' };
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
411
474
|
/* What a review shows of head against base, in a workspace: the commits
|
|
412
475
|
(with their whole messages), the true count, the files, the changes,
|
|
413
476
|
and what the changes alone could hide. `range` and `negatives` say
|
package/server/main.mjs
CHANGED
|
@@ -70,6 +70,10 @@ export const FILES = {
|
|
|
70
70
|
'/voice-core.js': ['web/voice-core.js', 'text/javascript; charset=utf-8'],
|
|
71
71
|
'/icons.js': ['web/icons.js', 'text/javascript; charset=utf-8'],
|
|
72
72
|
'/links.js': ['web/links.js', 'text/javascript; charset=utf-8'],
|
|
73
|
+
'/gitgraph.js': ['web/gitgraph.js', 'text/javascript; charset=utf-8'],
|
|
74
|
+
/* The review's diff, drawn by diff2html (vendored, pinned: web/vendor/diff2html/LICENSE). */
|
|
75
|
+
'/vendor/diff2html.js': ['web/vendor/diff2html/diff2html-ui-slim.min.js', 'text/javascript; charset=utf-8'],
|
|
76
|
+
'/vendor/diff2html.css': ['web/vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
|
|
73
77
|
'/vendor/xterm.js': [packageFile('@xterm/xterm', 'lib/xterm.js'), 'text/javascript; charset=utf-8'],
|
|
74
78
|
'/vendor/xterm.css': [packageFile('@xterm/xterm', 'css/xterm.css'), 'text/css; charset=utf-8'],
|
|
75
79
|
'/vendor/fit.js': [packageFile('@xterm/addon-fit', 'lib/addon-fit.js'), 'text/javascript; charset=utf-8'],
|
package/server/names.mjs
CHANGED
|
@@ -8,3 +8,34 @@ const NAME = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
|
8
8
|
export function isConversationName(value) {
|
|
9
9
|
return typeof value === 'string' && value.length <= NAME_MAX && NAME.test(value);
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/* THE NAME A PERSON READS is free: spaces, capitals, accents, emoji. It never
|
|
13
|
+
reaches a command line (the identifier above does), and the page shows it
|
|
14
|
+
as text only. Refused: what cannot be seen or reorders what is seen
|
|
15
|
+
(control characters, line breaks, zero-width and bidirectional marks), and
|
|
16
|
+
spaces at either end, which would make two names look alike. */
|
|
17
|
+
export const TITLE_MAX = 60;
|
|
18
|
+
const UNSEEN_RANGES = [[0x0, 0x1f], [0x7f, 0x9f], [0xad, 0xad], [0x61c, 0x61c], [0x180e, 0x180e], [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x206f], [0xfeff, 0xfeff], [0xfff9, 0xfffb]];
|
|
19
|
+
const UNSEEN = new RegExp(`[${UNSEEN_RANGES.map(([a, b]) => `\\u{${a.toString(16)}}-\\u{${b.toString(16)}}`).join('')}]`, 'u');
|
|
20
|
+
|
|
21
|
+
/* The reason it is refused, or '' when it may be a name. */
|
|
22
|
+
export function titleProblem(value) {
|
|
23
|
+
if (typeof value !== 'string' || value === '') return 'Give a name.';
|
|
24
|
+
if (UNSEEN.test(value)) return 'A name has no line breaks and no invisible or direction characters.';
|
|
25
|
+
if (value.trim() !== value) return 'A name does not start or end with a space.';
|
|
26
|
+
if ([...value].length > TITLE_MAX) return `A name is ${TITLE_MAX} characters at most.`;
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* The identifier drawn from a name: "Location voiture" -> location-voiture;
|
|
31
|
+
one taken -> location-voiture-2. `taken(id)` says whether it is. */
|
|
32
|
+
export function identifierFrom(title, taken = () => false) {
|
|
33
|
+
let base = String(title ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
34
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').replace(/-+$/, '');
|
|
35
|
+
if (!base) base = 'conversation';
|
|
36
|
+
for (let n = 1; ; n += 1) {
|
|
37
|
+
const tail = n === 1 ? '' : `-${n}`;
|
|
38
|
+
const id = `${base.slice(0, NAME_MAX - tail.length).replace(/-+$/, '')}${tail}`;
|
|
39
|
+
if (isConversationName(id) && !taken(id)) return id;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/server/toolbox.mjs
CHANGED
|
@@ -89,7 +89,7 @@ export class Toolbox {
|
|
|
89
89
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
90
90
|
const there = existsSync(`${dir}/${file}`) ? createHash('sha256').update(readFileSync(`${dir}/${file}`)).digest('hex') : '';
|
|
91
91
|
if (there !== sha256) { writeFileSync(`${dir}/.${file}.new`, bytes); renameSync(`${dir}/.${file}.new`, `${dir}/${file}`); }
|
|
92
|
-
const version = /-(\d+\.\d+\.\d+)\.tgz$/.exec(file)?.[1] ?? '';
|
|
92
|
+
const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\.tgz$/.exec(file)?.[1] ?? '';
|
|
93
93
|
tools.push({ name: source.name, version, file, sha256, usage: source.usage(`${TOOLS_PATH}/${file}`) });
|
|
94
94
|
}
|
|
95
95
|
const kept = new Set([...tools.map((t) => t.file), 'index.json', ...(this.#notes ? [NOTES] : [])]);
|