clavarium 0.2.0-next.4 → 0.2.0-next.6
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/deploy/install.sh +10 -3
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/server/api.mjs +19 -4
- package/server/conversations.mjs +104 -8
- package/server/gitkeeper.mjs +10 -5
- package/server/gitpush.mjs +21 -8
- package/server/instance.mjs +35 -0
- package/server/main.mjs +16 -8
- package/server/names.mjs +35 -0
- package/server/store.mjs +21 -0
- package/web/app.js +783 -215
- package/web/device.html +4 -3
- package/web/index.html +37 -12
- package/web/links.js +24 -8
- package/web/style.css +47 -1
package/deploy/install.sh
CHANGED
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
# server mode)
|
|
39
39
|
# --relay-installed the relay was set up already (by `clavarium online`):
|
|
40
40
|
# do not print the command to run on it
|
|
41
|
+
# --name NAME the name of this Clavarium, under its icon and in its
|
|
42
|
+
# tab (default: drawn from --domain); changed in Settings.
|
|
43
|
+
# Letters, digits, spaces, dots, hyphens: 30 at most
|
|
41
44
|
# --dry-run check the parameters, say what would be done, do nothing
|
|
42
45
|
#
|
|
43
46
|
# AFTER --local, going online is one command: `sudo clavarium online --relay
|
|
@@ -107,12 +110,12 @@ DEX_LAYER="680f1119fdbcdd7f0ad8985b2534ded871b08b5b77c40f337c38aebd062b59c2"
|
|
|
107
110
|
DEX_SHA256="217aa675738d1cbaf25fe8d7bc04cb00dbc60f91d8465b76b8d8af963d571337"
|
|
108
111
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
109
112
|
|
|
110
|
-
bad() { echo "install: $*" >&2; echo "usage: install.sh --domain NAME --operator EMAIL [--operator EMAIL...] [--port N] [--relay HOST [--relay-port N]] [--core-only] [--version X.Y.Z | --from-dir DIR] [--updates auto|asap|ask|off] [--update-window HH:MM-HH:MM] [--channel latest|next] [--dry-run]" >&2; echo " or: install.sh --local [--port N] [same options] [--dry-run] to try it on this machine, with no domain" >&2; exit 64; }
|
|
113
|
+
bad() { echo "install: $*" >&2; echo "usage: install.sh --domain NAME --operator EMAIL [--operator EMAIL...] [--port N] [--relay HOST [--relay-port N]] [--core-only] [--version X.Y.Z | --from-dir DIR] [--updates auto|asap|ask|off] [--update-window HH:MM-HH:MM] [--channel latest|next] [--name NAME] [--dry-run]" >&2; echo " or: install.sh --local [--port N] [same options] [--dry-run] to try it on this machine, with no domain" >&2; exit 64; }
|
|
111
114
|
refuse() { echo "install: REFUSED: $*" >&2; exit 78; }
|
|
112
115
|
step() { echo "== $*"; }
|
|
113
116
|
|
|
114
117
|
DOMAIN=""; OPERATORS=""; PORT="7717"; CORE_ONLY="no"; DRY="no"; RELAY=""; RELAY_PORT="22"; LOCAL="no"; KEEP_RELEASE="no"; RELAY_INSTALLED="no"
|
|
115
|
-
VERSION=""; FROM_DIR=""; UPDATES=""; WINDOW=""; CHANNEL=""
|
|
118
|
+
VERSION=""; FROM_DIR=""; UPDATES=""; WINDOW=""; CHANNEL=""; APP_NAME=""
|
|
116
119
|
while [ $# -gt 0 ]; do
|
|
117
120
|
case "$1" in
|
|
118
121
|
--domain) DOMAIN="${2:-}"; shift 2 || bad "--domain needs a value" ;;
|
|
@@ -129,6 +132,7 @@ while [ $# -gt 0 ]; do
|
|
|
129
132
|
--channel) CHANNEL="${2:-}"; shift 2 || bad "--channel needs a value" ;;
|
|
130
133
|
--keep-release) KEEP_RELEASE="yes"; shift ;;
|
|
131
134
|
--relay-installed) RELAY_INSTALLED="yes"; shift ;;
|
|
135
|
+
--name) APP_NAME="${2:-}"; [ -n "$APP_NAME" ] || bad "--name needs a value"; shift 2 ;;
|
|
132
136
|
--dry-run) DRY="yes"; shift ;;
|
|
133
137
|
*) bad "unknown parameter: $1" ;;
|
|
134
138
|
esac
|
|
@@ -170,6 +174,8 @@ fi
|
|
|
170
174
|
[ -z "$UPDATES" ] || [[ "$UPDATES" =~ ^(auto|asap|ask|off)$ ]] || bad "--updates is auto, asap, ask or off"
|
|
171
175
|
[ -z "$WINDOW" ] || { [[ "$WINDOW" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$ ]] && [ "${WINDOW%-*}" != "${WINDOW#*-}" ]; } || bad "--update-window is two times, like 02:00-05:00"
|
|
172
176
|
[ -z "$CHANNEL" ] || [[ "$CHANNEL" =~ ^(latest|next)$ ]] || bad "--channel is latest or next"
|
|
177
|
+
# It goes in the unit file, between quotes: plain characters only (any other is chosen in Settings).
|
|
178
|
+
[ -z "$APP_NAME" ] || [[ "$APP_NAME" =~ ^[A-Za-z0-9]([A-Za-z0-9\ ._-]{0,28}[A-Za-z0-9.])?$ ]] || bad "--name is letters, digits, spaces, dots and hyphens, 30 at most, like \"Home lab\""
|
|
173
179
|
# What the updates will be, as the settings file has them or as asked now.
|
|
174
180
|
had() { { sed -n "s/^$1=//p" "$UPDATE_CONF" 2>/dev/null || true; } | head -1; }
|
|
175
181
|
SHOW_MODE="${UPDATES:-$(had MODE)}"; SHOW_MODE="${SHOW_MODE:-auto}"
|
|
@@ -188,6 +194,7 @@ if [ "$DRY" = "yes" ]; then
|
|
|
188
194
|
would install Clavarium:
|
|
189
195
|
address $([ "$LOCAL" = "yes" ] && echo "http://127.0.0.1:$PORT, from this machine only (--local)" || echo "https://$DOMAIN")
|
|
190
196
|
operators $([ "$LOCAL" = "yes" ] && echo "whoever can open a page on this machine: no sign-in" || echo "$OPERATORS")
|
|
197
|
+
name $([ -n "$APP_NAME" ] && echo "$APP_NAME" || echo "drawn from the address; changed in Settings")
|
|
191
198
|
server 127.0.0.1:$PORT, as the account "$ACCOUNT", Node.js $NODE_VERSION
|
|
192
199
|
release $([ "$KEEP_RELEASE" = "yes" ] && echo "the one that runs, kept (--keep-release)" || [ -n "$VERSION" ] && echo "clavarium@$VERSION from npm, checked (integrity, provenance from github.com/clavarium/clavarium, npm audit signatures)" || echo "the working tree $FROM_DIR, as it is (development: not checked)")
|
|
193
200
|
updates $(case "$SHOW_MODE" in auto) echo "automatic, between ${SHOW_WINDOW%-*} and ${SHOW_WINDOW#*-}" ;; asap) echo "as soon as a version is out" ;; ask) echo "said in Settings, done when you say" ;; off) echo "off" ;; esac), channel $SHOW_CHANNEL
|
|
@@ -335,7 +342,7 @@ User=$ACCOUNT
|
|
|
335
342
|
Group=$ACCOUNT
|
|
336
343
|
WorkingDirectory=$CODE/current/app
|
|
337
344
|
EnvironmentFile=$ETC/server.env
|
|
338
|
-
ExecStart=$CODE/current/node/bin/node server/main.mjs --host "sudo -n $HOST_PROGRAM" --data $DATA --vault-key $VAULT/vault.key --port $PORT$([ "$LOCAL" = "yes" ] || echo " --operators $OPERATORS --public-origin https://$DOMAIN")
|
|
345
|
+
ExecStart=$CODE/current/node/bin/node server/main.mjs --host "sudo -n $HOST_PROGRAM" --data $DATA --vault-key $VAULT/vault.key --port $PORT$([ "$LOCAL" = "yes" ] || echo " --operators $OPERATORS --public-origin https://$DOMAIN")$([ -z "$APP_NAME" ] || echo " --name \"$APP_NAME\"")
|
|
339
346
|
Restart=on-failure
|
|
340
347
|
RestartSec=3
|
|
341
348
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.0-next.
|
|
3
|
+
"version": "0.2.0-next.6",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.0-next.
|
|
9
|
+
"version": "0.2.0-next.6",
|
|
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.10.0-next.
|
|
14
|
+
"clavarium-parley": "0.10.0-next.3",
|
|
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.10.0-next.
|
|
690
|
-
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.
|
|
691
|
-
"integrity": "sha512
|
|
689
|
+
"version": "0.10.0-next.3",
|
|
690
|
+
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.10.0-next.3.tgz",
|
|
691
|
+
"integrity": "sha512-a750ZvTUDngz8nMdmja3/slCYCUCxM24uFEdgM1X4dNZ666Pf3HEPBbN9GwGgU6F+Ozc1JML5o87tCAMqcRyGQ==",
|
|
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.2.0-next.
|
|
3
|
+
"version": "0.2.0-next.6",
|
|
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.10.0-next.
|
|
46
|
+
"clavarium-parley": "0.10.0-next.3",
|
|
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
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
* GET /api/conversations/<name>/guide what its tool is told about where it is: the exact texts, the owner's lines
|
|
23
23
|
* POST /api/conversations/<name>/guide {lines}: the owner's own lines for it (short, plain text)
|
|
24
24
|
* POST /api/conversations/<name>/guide/tell its guide written again, and one line typed in its terminal to read it
|
|
25
|
+
* POST /api/conversations/<name>/group {group}: its folder in the list ('' none); first in it
|
|
26
|
+
* POST /api/conversations/<name>/move {to: up|down}: one place among those of its folder
|
|
27
|
+
* POST /api/groups/rename {from, to}: for all its conversations
|
|
28
|
+
* POST /api/groups/move {group, to: up|down}
|
|
29
|
+
* POST /api/order {order?, groups?}: the whole order, as a drag and drop leaves it
|
|
30
|
+
* GET /api/instance {name, defaultName, given}: the name of this Clavarium
|
|
31
|
+
* POST /api/instance {name}: '' for the one drawn from its address
|
|
25
32
|
* GET /api/resources
|
|
26
33
|
* POST /api/resources {name} | {name, type: 'git', from?, via?: {method, key?, user?, secret?}}
|
|
27
34
|
* GET /api/git-keys SSH keys made here: the public half only
|
|
@@ -38,7 +45,7 @@
|
|
|
38
45
|
* POST /api/updates/back { version }: go back to a release that is kept (in the background)
|
|
39
46
|
* GET /api/git/<name>/outgoing?target=<branch>&branch=<branch> what would go from the agent's branch, to that branch of the remote
|
|
40
47
|
* 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
|
|
48
|
+
* GET /api/git/<name>/commit?id=<sha> one commit, as a review shows it (&base=<sha>: the commits from base to it)
|
|
42
49
|
* POST /api/git/<name>/push {head, target?, sure?}: exactly that reviewed commit, to exactly that branch
|
|
43
50
|
* GET /api/git/<name>/keep origin kept up to date, checks, automatic sending, log
|
|
44
51
|
* POST /api/git/<name>/keep {refreshMinutes?, alert?, checkCommand?, checkSeconds?, runHook?, requireForSend?, fsck?}
|
|
@@ -104,7 +111,7 @@ function readBody(request, max = BODY_MAX) {
|
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
/* Returns true when the request was an API request (answered here). */
|
|
107
|
-
export async function handleApi(request, response, conversations, headers, resources, originOk = sameOrigin, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates) {
|
|
114
|
+
export async function handleApi(request, response, conversations, headers, resources, originOk = sameOrigin, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates, instance) {
|
|
108
115
|
const { pathname } = new URL(request.url ?? '/', 'http://local');
|
|
109
116
|
if (!pathname.startsWith('/api/')) return false;
|
|
110
117
|
const send = (status, body) => {
|
|
@@ -194,6 +201,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
194
201
|
if (pathname === '/api/git-keys' && gitAccess) return send(200, gitAccess.keys()), true;
|
|
195
202
|
if (pathname === '/api/git-hosts' && gitAccess) return send(200, gitAccess.hosts()), true;
|
|
196
203
|
if (pathname === '/api/platform' && platform) return send(200, platform), true;
|
|
204
|
+
if (pathname === '/api/instance' && instance) return send(200, instance.about()), true;
|
|
197
205
|
if (pathname === '/api/shelf' && shelf) return send(200, shelf.about()), true;
|
|
198
206
|
/* Settings, Updates (updates.mjs): what runs, what is out, the mode, what happened. */
|
|
199
207
|
if (pathname === '/api/updates' && updates) return send(200, updates.about()), true;
|
|
@@ -218,7 +226,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
218
226
|
/* A repository: where its commits go, and what would go now. */
|
|
219
227
|
const repo = /^\/api\/git\/([a-z0-9-]{1,32})(\/outgoing|\/keep)?$/.exec(pathname);
|
|
220
228
|
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;
|
|
229
|
+
if (drawn && gitReview) return send(200, drawn[2] === 'graph' ? await gitReview.graph(drawn[1]) : await gitReview.commit(drawn[1], query.get('id'), query.get('base'))), true;
|
|
222
230
|
if (repo && repo[2] === '/keep' && gitKeeper) return send(200, gitKeeper.about(repo[1])), true;
|
|
223
231
|
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;
|
|
224
232
|
const watched = /^\/api\/conversations\/([^/]+)\/signin$/.exec(pathname);
|
|
@@ -293,7 +301,7 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
293
301
|
if (dropped && secrets) { secrets.remove(dropped[1]); return send(200, { ok: true }), true; }
|
|
294
302
|
if (pathname === '/api/alerts' && alerts) return send(201, alerts.add(body.subscription, body.label)), true;
|
|
295
303
|
if (pathname === '/api/alerts/remove' && alerts) return send(200, alerts.remove(String(body.endpoint ?? ''))), true;
|
|
296
|
-
if (pathname === '/api/alerts/test' && alerts) return send(200, { reached: await alerts.tell({ title: 'Clavarium', body: 'This is what an alert looks like.', url: '/' }) }), true;
|
|
304
|
+
if (pathname === '/api/alerts/test' && alerts) return send(200, { reached: await alerts.tell({ title: instance?.name() ?? 'Clavarium', body: 'This is what an alert looks like.', url: '/' }) }), true;
|
|
297
305
|
if (pathname === '/api/talk/settings' && talk) return send(200, talk.setWords({ lang: body.lang, own: body.own })), true;
|
|
298
306
|
if (pathname === '/api/talk/thread' && talk) return send(200, talk.setThread({ levels: body.levels })), true;
|
|
299
307
|
const guiding = /^\/api\/conversations\/([^/]+)\/guide(\/tell)?$/.exec(pathname);
|
|
@@ -428,6 +436,13 @@ export async function handleApi(request, response, conversations, headers, resou
|
|
|
428
436
|
if (asking) return send(200, conversations.setAskFirst(decodeURIComponent(asking[1]), body.on)), true;
|
|
429
437
|
const naming = /^\/api\/conversations\/([^/]+)\/chat-name$/.exec(pathname);
|
|
430
438
|
if (naming) return send(200, conversations.setChatName(decodeURIComponent(naming[1]), body.name)), true;
|
|
439
|
+
/* The list: folders and order, the same on every device (conversations.mjs). */
|
|
440
|
+
if (pathname === '/api/instance' && instance) return send(200, instance.setName(body.name)), true;
|
|
441
|
+
if (pathname === '/api/order') return send(200, conversations.setOrder({ order: body.order, groups: body.groups })), true;
|
|
442
|
+
if (pathname === '/api/groups/rename') return send(200, conversations.renameGroup(body.from, body.to)), true;
|
|
443
|
+
if (pathname === '/api/groups/move') return send(200, conversations.moveGroup(body.group, body.to)), true;
|
|
444
|
+
const grouping = /^\/api\/conversations\/([^/]+)\/(group|move)$/.exec(pathname);
|
|
445
|
+
if (grouping) return send(200, grouping[2] === 'group' ? conversations.setGroup(decodeURIComponent(grouping[1]), body.group) : conversations.move(decodeURIComponent(grouping[1]), body.to)), true;
|
|
431
446
|
const titling = /^\/api\/conversations\/([^/]+)\/title$/.exec(pathname);
|
|
432
447
|
if (titling) return send(200, conversations.setTitle(decodeURIComponent(titling[1]), body.title)), true;
|
|
433
448
|
const hiding = /^\/api\/conversations\/([^/]+)\/hidden$/.exec(pathname);
|
package/server/conversations.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
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, identifierFrom, titleProblem } from './names.mjs';
|
|
9
|
+
import { isConversationName, identifierFrom, titleProblem, groupProblem } from './names.mjs';
|
|
10
|
+
import { JsonFile } from './store.mjs';
|
|
10
11
|
import { attachCommand, SOCKET } from './tmux.mjs';
|
|
11
12
|
import { WORK, VM_USER, SHARING_STEP } from './vm.mjs';
|
|
12
13
|
import { resumeOrFresh } from './tools.mjs';
|
|
@@ -60,7 +61,7 @@ const buildProgress = (since) => ({ kind: 'build', steps: ['starting', 'preparin
|
|
|
60
61
|
const firstLine = (text) => String(text ?? '').trim().split('\n').filter(Boolean).pop()?.slice(0, 200) ?? '';
|
|
61
62
|
|
|
62
63
|
export class Conversations {
|
|
63
|
-
#store; #vms; #tools; #stopDelayMs; #timers = new Map(); #watching = new Map(); #fresh = new Set(); #mountAgain = new Set(); #uid; #gid;
|
|
64
|
+
#layout; #store; #vms; #tools; #stopDelayMs; #timers = new Map(); #watching = new Map(); #fresh = new Set(); #mountAgain = new Set(); #uid; #gid;
|
|
64
65
|
/* Set by the server: prepares the VM before the tool is attached, and
|
|
65
66
|
names the program the tool is started through. See secrets.mjs. */
|
|
66
67
|
prepare = async () => {};
|
|
@@ -90,8 +91,8 @@ export class Conversations {
|
|
|
90
91
|
/* `uid`/`gid`: the account this server runs as; files the tool writes in
|
|
91
92
|
its kept work folder belong to it on the host. */
|
|
92
93
|
#startingMs; #answerMs; #pollMs;
|
|
93
|
-
constructor({ store, vms, tools, stopDelayMs = STOP_DELAY_MS, uid = process.getuid(), gid = process.getgid(), startingMs = STARTING_MS, answerMs = ANSWER_MS, pollMs = POLL_MS }) {
|
|
94
|
-
this.#store = store; this.#vms = vms; this.#tools = tools; this.#stopDelayMs = stopDelayMs; this.#uid = uid; this.#gid = gid;
|
|
94
|
+
constructor({ store, vms, tools, layout = new JsonFile(undefined, {}), stopDelayMs = STOP_DELAY_MS, uid = process.getuid(), gid = process.getgid(), startingMs = STARTING_MS, answerMs = ANSWER_MS, pollMs = POLL_MS }) {
|
|
95
|
+
this.#layout = layout; this.#store = store; this.#vms = vms; this.#tools = tools; this.#stopDelayMs = stopDelayMs; this.#uid = uid; this.#gid = gid;
|
|
95
96
|
this.#startingMs = startingMs; this.#answerMs = answerMs; this.#pollMs = pollMs;
|
|
96
97
|
/* A build interrupted by a restart of this server cannot be resumed
|
|
97
98
|
blindly: it is marked failed, and says so. */
|
|
@@ -177,11 +178,30 @@ export class Conversations {
|
|
|
177
178
|
/* The sign-in rules of a conversation's tool, or undefined. */
|
|
178
179
|
signinRules(name) { return this.#tools.find((t) => t.id === this.#store.get(name)?.tool)?.signin; }
|
|
179
180
|
|
|
181
|
+
/* THE ORDER OF THE LIST, the same on every device: the conversations
|
|
182
|
+
* (one order; a folder shows its own in it) and the folders. Kept in a
|
|
183
|
+
* file of its own; what is not in it (made before, or by an older
|
|
184
|
+
* version) comes after, in the order it was made. A folder is only a
|
|
185
|
+
* word on its conversations: with none left, it is gone. */
|
|
186
|
+
#order() {
|
|
187
|
+
const all = this.#store.all();
|
|
188
|
+
const names = new Set(all.map((c) => c.name));
|
|
189
|
+
const kept = this.#layout.get();
|
|
190
|
+
const order = [...new Set((Array.isArray(kept.order) ? kept.order : []).filter((n) => names.has(n)))];
|
|
191
|
+
for (const c of all) if (!order.includes(c.name)) order.push(c.name);
|
|
192
|
+
const present = [...new Set(all.map((c) => c.group).filter(Boolean))];
|
|
193
|
+
const groups = [...new Set((Array.isArray(kept.groups) ? kept.groups : []).filter((g) => present.includes(g)))];
|
|
194
|
+
for (const g of present) if (!groups.includes(g)) groups.push(g);
|
|
195
|
+
return { order, groups };
|
|
196
|
+
}
|
|
197
|
+
#keepOrder({ order, groups }) { this.#layout.set({ order, groups }); }
|
|
198
|
+
|
|
180
199
|
list() {
|
|
181
|
-
|
|
200
|
+
const { order, groups } = this.#order();
|
|
201
|
+
return this.#store.all().sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)).map((c) => {
|
|
182
202
|
const tool = this.#tools.find((t) => t.id === c.tool);
|
|
183
203
|
/* An AI tool has an instruction file; a shell has none: what is dictated there is never marked. */
|
|
184
|
-
return { ...c, toolLabel: tool?.label ?? c.tool, memoryMinMb: tool?.memoryMb, resumes: Boolean(tool?.resume), rights: Boolean(tool?.rights), ai: Boolean(tool?.instructions), voiceMark: Boolean(tool?.instructions) && c.voiceMark !== false };
|
|
204
|
+
return { ...c, rank: order.indexOf(c.name), ...(c.group ? { groupRank: groups.indexOf(c.group) } : {}), toolLabel: tool?.label ?? c.tool, memoryMinMb: tool?.memoryMb, resumes: Boolean(tool?.resume), rights: Boolean(tool?.rights), ai: Boolean(tool?.instructions), voiceMark: Boolean(tool?.instructions) && c.voiceMark !== false };
|
|
185
205
|
});
|
|
186
206
|
}
|
|
187
207
|
|
|
@@ -211,8 +231,9 @@ export class Conversations {
|
|
|
211
231
|
return { name: id, title: title !== undefined && title !== id ? title : undefined };
|
|
212
232
|
}
|
|
213
233
|
|
|
214
|
-
create({ name: given, title: read, tool, memoryMb, workOnDisk }) {
|
|
234
|
+
create({ name: given, title: read, tool, memoryMb, workOnDisk, group }) {
|
|
215
235
|
const { name, title } = this.#naming({ name: given, title: read });
|
|
236
|
+
if (group !== undefined) { const problem = groupProblem(group); if (problem) throw new Refusal(400, problem); }
|
|
216
237
|
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.');
|
|
217
238
|
const chosen = this.#tools.find((t) => t.id === tool);
|
|
218
239
|
if (!chosen) throw new Refusal(400, 'Unknown tool.');
|
|
@@ -222,8 +243,11 @@ export class Conversations {
|
|
|
222
243
|
}
|
|
223
244
|
if (this.#store.get(name)) throw new Refusal(409, 'A conversation already has this name.');
|
|
224
245
|
const now = new Date().toISOString();
|
|
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) };
|
|
246
|
+
const conversation = { name, ...(title ? { title } : {}), ...(group ? { group } : {}), 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) };
|
|
247
|
+
/* A new one comes first in its folder (or first of those in none). */
|
|
248
|
+
const { order, groups } = this.#order();
|
|
226
249
|
this.#store.put(conversation);
|
|
250
|
+
this.#keepOrder({ order: [name, ...order], groups: group && !groups.includes(group) ? [...groups, group] : groups });
|
|
227
251
|
this.built = this.#build(name, chosen, memory);
|
|
228
252
|
return conversation;
|
|
229
253
|
}
|
|
@@ -483,6 +507,78 @@ export class Conversations {
|
|
|
483
507
|
return this.#store.get(name);
|
|
484
508
|
}
|
|
485
509
|
|
|
510
|
+
/* ITS FOLDER in the list ('' for none): first among those of that folder. */
|
|
511
|
+
setGroup(name, group) {
|
|
512
|
+
const c = this.#store.get(name);
|
|
513
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
514
|
+
const problem = groupProblem(group);
|
|
515
|
+
if (problem) throw new Refusal(400, problem);
|
|
516
|
+
if ((c.group ?? '') === group) return c;
|
|
517
|
+
const { order, groups } = this.#order();
|
|
518
|
+
this.#store.patch(name, { group: group || undefined });
|
|
519
|
+
this.#keepOrder({ order: [name, ...order.filter((n) => n !== name)], groups: group && !groups.includes(group) ? [...groups, group] : groups });
|
|
520
|
+
return this.#store.get(name);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* A folder renamed, for all its conversations; to the name of another, they join it. */
|
|
524
|
+
renameGroup(from, to) {
|
|
525
|
+
const problem = groupProblem(to);
|
|
526
|
+
if (problem) throw new Refusal(400, problem);
|
|
527
|
+
if (!to) throw new Refusal(400, 'Give the new name of the folder.');
|
|
528
|
+
const inside = this.#store.all().filter((c) => typeof from === 'string' && from && c.group === from);
|
|
529
|
+
if (inside.length === 0) throw new Refusal(404, 'No such folder.');
|
|
530
|
+
const { order, groups } = this.#order();
|
|
531
|
+
for (const c of inside) this.#store.patch(c.name, { group: to });
|
|
532
|
+
const renamed = groups.includes(to) ? groups.filter((g) => g !== from) : groups.map((g) => (g === from ? to : g));
|
|
533
|
+
this.#keepOrder({ order, groups: renamed });
|
|
534
|
+
return { renamed: inside.length, group: to };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* One place up or down among the others of its folder (`to`: 'up' or 'down').
|
|
538
|
+
A hidden one is passed over by one that is shown: it is not in sight. */
|
|
539
|
+
move(name, to) {
|
|
540
|
+
const c = this.#store.get(name);
|
|
541
|
+
if (!c) throw new Refusal(404, 'No such conversation.');
|
|
542
|
+
if (to !== 'up' && to !== 'down') throw new Refusal(400, 'Say up or down.');
|
|
543
|
+
const { order, groups } = this.#order();
|
|
544
|
+
const byName = new Map(this.#store.all().map((x) => [x.name, x]));
|
|
545
|
+
const peers = order.filter((n) => (byName.get(n).group ?? '') === (c.group ?? '') && (c.hidden === true || byName.get(n).hidden !== true || n === name));
|
|
546
|
+
const at = peers.indexOf(name);
|
|
547
|
+
const other = peers[to === 'up' ? at - 1 : at + 1];
|
|
548
|
+
if (other === undefined) return { order: this.list().map((x) => x.name) };
|
|
549
|
+
const i = order.indexOf(name); const j = order.indexOf(other);
|
|
550
|
+
[order[i], order[j]] = [order[j], order[i]];
|
|
551
|
+
this.#keepOrder({ order, groups });
|
|
552
|
+
return { order };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* A folder one place up or down among the folders. */
|
|
556
|
+
moveGroup(group, to) {
|
|
557
|
+
if (to !== 'up' && to !== 'down') throw new Refusal(400, 'Say up or down.');
|
|
558
|
+
const { order, groups } = this.#order();
|
|
559
|
+
const at = groups.indexOf(group);
|
|
560
|
+
if (typeof group !== 'string' || at < 0) throw new Refusal(404, 'No such folder.');
|
|
561
|
+
const j = to === 'up' ? at - 1 : at + 1;
|
|
562
|
+
if (j >= 0 && j < groups.length) [groups[at], groups[j]] = [groups[j], groups[at]];
|
|
563
|
+
this.#keepOrder({ order, groups });
|
|
564
|
+
return { groups };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/* A whole order, as a drag and drop leaves it: names it does not know are
|
|
568
|
+
left out, and those it forgets keep their places after the others. */
|
|
569
|
+
setOrder({ order: wanted, groups: wantedGroups } = {}) {
|
|
570
|
+
const { order, groups } = this.#order();
|
|
571
|
+
const pick = (list, known) => {
|
|
572
|
+
if (list === undefined) return known;
|
|
573
|
+
if (!Array.isArray(list) || list.length > 10_000 || list.some((x) => typeof x !== 'string')) throw new Refusal(400, 'Give a list of names.');
|
|
574
|
+
const chosen = [...new Set(list.filter((x) => known.includes(x)))];
|
|
575
|
+
return [...chosen, ...known.filter((x) => !chosen.includes(x))];
|
|
576
|
+
};
|
|
577
|
+
const next = { order: pick(wanted, order), groups: pick(wantedGroups, groups) };
|
|
578
|
+
this.#keepOrder(next);
|
|
579
|
+
return next;
|
|
580
|
+
}
|
|
581
|
+
|
|
486
582
|
/* What is dictated into its terminal starts with a microphone sign, once
|
|
487
583
|
per message (true, the default for a tool that is an AI), or not. */
|
|
488
584
|
setVoiceMark(name, on) {
|
package/server/gitkeeper.mjs
CHANGED
|
@@ -198,6 +198,11 @@ const TODO = Object.freeze({
|
|
|
198
198
|
/* Reasons whose sending stays stuck until somebody looks. */
|
|
199
199
|
const GRAVE_WHY = new Set(['refused-forced', 'refused-unknown-unreachable', 'refused-workspace']);
|
|
200
200
|
const commitsOf = (n) => `${n} commit${n === 1 ? '' : 's'}`;
|
|
201
|
+
/* A commit as a line of the chat names it: its first seven characters (the page makes them a link to it). */
|
|
202
|
+
const short = (id) => (typeof id === 'string' && /^[0-9a-f]{40}$/.test(id) ? id.slice(0, 7) : '');
|
|
203
|
+
const upTo = (id) => (short(id) ? ` (up to ${short(id)})` : '');
|
|
204
|
+
/* "See the N commits": from the tip before to the tip now, as the review shows them; none for a new branch. */
|
|
205
|
+
const seeCommits = (name, head, base, n) => (short(head) && short(base) && Number.isInteger(n) && n > 0 ? [`commits:${name}.${head.slice(0, 12)}.${base.slice(0, 12)}.${Math.min(n, 9999)}`] : []);
|
|
201
206
|
|
|
202
207
|
export class GitKeeper {
|
|
203
208
|
#store; #review; #resources; #checks; #alerts; #now; #tellAgent; #thread; #held = new Map(); #results = new Map(); #running = new Set(); #telling = new Map();
|
|
@@ -366,7 +371,7 @@ export class GitKeeper {
|
|
|
366
371
|
#say(name, conversations, event) {
|
|
367
372
|
let attached;
|
|
368
373
|
try { attached = Object.keys(this.#resources.list().find((x) => x.name === name)?.attachments ?? {}); } catch { return; }
|
|
369
|
-
for (const c of conversations) if (attached.includes(c)) { try { this.#thread(c, { ...event, actions: [`review:${name}`] }); } catch { /* a chat never stops git */ } }
|
|
374
|
+
for (const c of conversations) if (attached.includes(c)) { try { this.#thread(c, { ...event, actions: [...(event.actions ?? []), `review:${name}`] }); } catch { /* a chat never stops git */ } }
|
|
370
375
|
}
|
|
371
376
|
#attached(name) { try { return Object.entries(this.#resources.list().find((x) => x.name === name)?.attachments ?? {}).filter(([, access]) => access && access !== 'none').map(([c]) => c).sort(); } catch { return []; } }
|
|
372
377
|
/* Marked as told only once somebody was: a line typed to no machine
|
|
@@ -427,8 +432,8 @@ export class GitKeeper {
|
|
|
427
432
|
const brought = result.ok ? (result.brought ?? []) : [];
|
|
428
433
|
if (brought.length) {
|
|
429
434
|
const total = brought.reduce((n, x) => n + x.commits, 0);
|
|
430
|
-
const where = brought.slice(0, 4).map((x) => `${x.commits} on ${x.branch}`).join(', ') + (brought.length > 4 ? `, and ${brought.length - 4} more branch(es)` : '');
|
|
431
|
-
this.#say(name, this.#attached(name), { kind: 'origin', text: `Origin of ${name} updated: ${total} new commit${total === 1 ? '' : 's'} (${where}).`, topic: 'Origin updated', brief: name });
|
|
435
|
+
const where = brought.slice(0, 4).map((x) => `${x.commits} on ${x.branch}${upTo(x.head)}`).join(', ') + (brought.length > 4 ? `, and ${brought.length - 4} more branch(es)` : '');
|
|
436
|
+
this.#say(name, this.#attached(name), { kind: 'origin', text: `Origin of ${name} updated: ${total} new commit${total === 1 ? '' : 's'} (${where}).`, topic: 'Origin updated', brief: name, actions: brought.length === 1 ? seeCommits(name, brought[0].head, brought[0].base, brought[0].commits) : [] });
|
|
432
437
|
}
|
|
433
438
|
if (result.ok && Object.keys(record.blocked ?? {}).length > 0) void this.#settle(name).catch(() => {});
|
|
434
439
|
}
|
|
@@ -490,7 +495,7 @@ export class GitKeeper {
|
|
|
490
495
|
}
|
|
491
496
|
try {
|
|
492
497
|
const sent = await this.#review.push(r.name, body);
|
|
493
|
-
if (!sent.already) this.#say(r.name, this.#attached(r.name), { kind: 'sent-by-hand', text: `Sent by hand to ${sent.branch} of ${r.name}: ${commitsOf(sent.commits)}${sent.made ? ' (a new branch)' : ''}.`, topic: 'Sent', brief: sent.branch });
|
|
498
|
+
if (!sent.already) this.#say(r.name, this.#attached(r.name), { kind: 'sent-by-hand', text: `Sent by hand to ${sent.branch} of ${r.name}: ${commitsOf(sent.commits)}${upTo(sent.head)}${sent.made ? ' (a new branch)' : ''}.`, topic: 'Sent', brief: sent.branch, actions: seeCommits(r.name, sent.head, sent.base, sent.commits) });
|
|
494
499
|
return sent;
|
|
495
500
|
} catch (error) {
|
|
496
501
|
if (error instanceof GitFailure) this.#sendFailed(r.name, error, { branch: body?.target, head: body?.head });
|
|
@@ -604,7 +609,7 @@ export class GitKeeper {
|
|
|
604
609
|
if (sent.already) { this.#log(name, { branch: b, head: tip.id, conversation, ok: true, what: `Already on the remote: ${b}.` }, { sent: true, by: conversation }); return true; }
|
|
605
610
|
this.#log(name, { branch: b, head: tip.id, conversation, ok: true, what: `Sent automatically for ${conversation}: ${sent.commits} commit(s) to ${sent.branch}${sent.made ? ' (a new branch)' : ''}, ${checked}.${sent.note ? ` ${sent.note}` : ''}` }, { sent: true, by: conversation });
|
|
606
611
|
if (alert) this.#tell(`${name}: sent to ${sent.branch}`, `${sent.commits} commit(s), automatically for ${conversation}, ${checked}.`);
|
|
607
|
-
this.#say(name, [conversation], { kind: 'sent', text: `Sent to ${sent.branch} of ${name}: ${commitsOf(sent.commits)}${sent.made ? ' (a new branch)' : ''}, ${checked}.`, topic: 'Sent', brief: sent.branch });
|
|
612
|
+
this.#say(name, [conversation], { kind: 'sent', text: `Sent to ${sent.branch} of ${name}: ${commitsOf(sent.commits)}${upTo(sent.head)}${sent.made ? ' (a new branch)' : ''}, ${checked}.`, topic: 'Sent', brief: sent.branch, actions: seeCommits(name, sent.head, sent.base, sent.commits) });
|
|
608
613
|
return true;
|
|
609
614
|
} catch (error) {
|
|
610
615
|
const kind = error instanceof GitFailure ? error.kind : 'error';
|
package/server/gitpush.mjs
CHANGED
|
@@ -259,7 +259,7 @@ export class GitReview {
|
|
|
259
259
|
if (before.get(b) === now || rewritten.some((w) => w.branch === b) || (ours && ours.branch === b && ours.head === now)) continue;
|
|
260
260
|
const counted = await this.#runner.in(reference, ['rev-list', '--count', '--stdin', '--end-of-options'], { input: `${now}\n${had}\n`, deadlineMs: 120_000 }).catch(() => null);
|
|
261
261
|
const commits = Number(counted?.code === 0 ? counted.stdout.toString('latin1').trim() : 0) || 0;
|
|
262
|
-
if (commits > 0) brought.push({ branch: b, commits });
|
|
262
|
+
if (commits > 0) brought.push({ branch: b, commits, head: now, base: before.get(b) ?? null });
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
if (made) await this.#resources.shareOrigin(r.name).catch(() => {});
|
|
@@ -456,18 +456,31 @@ export class GitReview {
|
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
/* ONE COMMIT, as a review shows it: against its first parent (or
|
|
459
|
-
nothing, for the first commit).
|
|
460
|
-
|
|
459
|
+
nothing, for the first commit). Or, given `base`, the commits from
|
|
460
|
+
base to it (what a line of the chat said was sent or brought). Named
|
|
461
|
+
by their identifier, whole or its first 7 characters at least, as a
|
|
462
|
+
line of the chat shows them. Never sent from here. */
|
|
463
|
+
async commit(name, id, base) {
|
|
461
464
|
const r = this.#repository(name);
|
|
462
465
|
const asked = String(id ?? '');
|
|
463
|
-
|
|
466
|
+
const from = base === null || base === undefined || base === '' ? '' : String(base);
|
|
467
|
+
const SHORT = /^[0-9a-f]{7,40}$/;
|
|
468
|
+
if (!SHORT.test(asked) || (from && !SHORT.test(from))) throw new Refusal(400, 'A commit is named by its identifier (7 to 40 hexadecimal characters).');
|
|
464
469
|
return this.#withRead(r, async (got) => {
|
|
465
470
|
const p = await this.#runner.in(got.path, ['rev-list', '--parents', '--max-count=1', '--end-of-options', asked], { deadlineMs: 60_000 });
|
|
466
471
|
const ids = p.stdout.toString('latin1').trim().split(' ');
|
|
467
|
-
if (p.code !== 0 || ids[0]
|
|
472
|
+
if (p.code !== 0 || !isId(ids[0]) || !ids[0].startsWith(asked)) throw new Refusal(404, 'This commit is not in the repository.');
|
|
473
|
+
const whole = ids[0];
|
|
474
|
+
if (from) {
|
|
475
|
+
const q = await this.#runner.in(got.path, ['rev-list', '--max-count=1', '--end-of-options', from], { deadlineMs: 60_000 });
|
|
476
|
+
const was = q.stdout.toString('latin1').trim();
|
|
477
|
+
if (q.code !== 0 || !isId(was) || !was.startsWith(from)) throw new Refusal(404, 'This commit is not in the repository.');
|
|
478
|
+
const shown = await this.#compare(got.path, { head: whole, base: was, range: [whole], negatives: [was] });
|
|
479
|
+
return { ...shown, id: whole, base: was, parents: [was], head: whole, state: 'commit', message: '' };
|
|
480
|
+
}
|
|
468
481
|
const parents = ids.slice(1).filter(isId);
|
|
469
|
-
const shown = await this.#compare(got.path, { head:
|
|
470
|
-
return { ...shown, id:
|
|
482
|
+
const shown = await this.#compare(got.path, { head: whole, base: parents[0] ?? EMPTY_TREE, range: [whole], negatives: parents });
|
|
483
|
+
return { ...shown, id: whole, parents, head: whole, state: 'commit', message: parents.length > 1 ? 'A merge: its changes against its first parent.' : '' };
|
|
471
484
|
});
|
|
472
485
|
}
|
|
473
486
|
|
|
@@ -578,7 +591,7 @@ export class GitReview {
|
|
|
578
591
|
if (!isId(head) || (review.remoteTip !== null && review.remoteTip !== undefined && !isId(review.remoteTip))) throw new GitFailure(409, 'look', 'Look again at what would be sent.');
|
|
579
592
|
let remoteTip = review.remoteTip ?? null;
|
|
580
593
|
let count = review.count;
|
|
581
|
-
const sent = (extra) => ({ ok: true, branch: target, from: branch, head, commits: count ?? 0, made: !remoteTip, already: false, ...extra });
|
|
594
|
+
const sent = (extra) => ({ ok: true, branch: target, from: branch, head, base: remoteTip, commits: count ?? 0, made: !remoteTip, already: false, ...extra });
|
|
582
595
|
/* (1) What the agent has now, read again into a fresh workspace. */
|
|
583
596
|
let got;
|
|
584
597
|
try { got = await this.#read(r); } catch (error) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/* THE NAME OF THIS CLAVARIUM: in the title of its tab, above its list, in
|
|
2
|
+
* its alerts, and under its icon once installed. Drawn from its address
|
|
3
|
+
* (clav.example.com -> Clav; "Clavarium" with none), given at the install
|
|
4
|
+
* (--name), changed in Settings. The manifest is made here, from the one in
|
|
5
|
+
* web/, with that name -- or, for one device, the name chosen there when
|
|
6
|
+
* installing (a parameter of the manifest's address: nothing is kept). */
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { JsonFile } from './store.mjs';
|
|
9
|
+
import { appNameFrom, appNameProblem, shortAppName } from './names.mjs';
|
|
10
|
+
import { Refusal } from './conversations.mjs';
|
|
11
|
+
|
|
12
|
+
const TEMPLATE = JSON.parse(readFileSync(new URL('../web/manifest.webmanifest', import.meta.url), 'utf8'));
|
|
13
|
+
|
|
14
|
+
export class Instance {
|
|
15
|
+
#file; #fallback;
|
|
16
|
+
constructor({ file = new JsonFile(undefined, {}), origin, given } = {}) {
|
|
17
|
+
this.#file = file;
|
|
18
|
+
this.#fallback = given && !appNameProblem(given) ? given : appNameFrom(origin);
|
|
19
|
+
}
|
|
20
|
+
name() { const kept = this.#file.get().name; return typeof kept === 'string' && !appNameProblem(kept) ? kept : this.#fallback; }
|
|
21
|
+
about() { return { name: this.name(), defaultName: this.#fallback, given: this.name() !== this.#fallback }; }
|
|
22
|
+
/* '' gives back the name drawn from the address. */
|
|
23
|
+
setName(name) {
|
|
24
|
+
if (typeof name !== 'string') throw new Refusal(400, 'Give a name, or nothing for the usual one.');
|
|
25
|
+
const said = name.trim();
|
|
26
|
+
if (said) { const problem = appNameProblem(said); if (problem) throw new Refusal(400, problem); }
|
|
27
|
+
this.#file.set(said && said !== this.#fallback ? { name: said } : {});
|
|
28
|
+
return this.about();
|
|
29
|
+
}
|
|
30
|
+
/* The manifest, as JSON; `asked`: the name one device chose, used only when it is a name. */
|
|
31
|
+
manifest(asked) {
|
|
32
|
+
const name = typeof asked === 'string' && asked && !appNameProblem(asked) ? asked : this.name();
|
|
33
|
+
return JSON.stringify({ ...TEMPLATE, name, short_name: shortAppName(name) }, null, 2);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/server/main.mjs
CHANGED
|
@@ -21,7 +21,8 @@ import { attachBridge } from './bridge.mjs';
|
|
|
21
21
|
import { attachCommand } from './tmux.mjs';
|
|
22
22
|
import { handleApi } from './api.mjs';
|
|
23
23
|
import { Conversations, DRAWN_MS } from './conversations.mjs';
|
|
24
|
-
import { Store } from './store.mjs';
|
|
24
|
+
import { Store, JsonFile } from './store.mjs';
|
|
25
|
+
import { Instance } from './instance.mjs';
|
|
25
26
|
import { loadTools } from './tools.mjs';
|
|
26
27
|
import { Vms, runner } from './vm.mjs';
|
|
27
28
|
import { Resources } from './resources.mjs';
|
|
@@ -114,7 +115,9 @@ const withoutTmux = (env) => Object.fromEntries(Object.entries(env).filter(([key
|
|
|
114
115
|
|
|
115
116
|
const REFUSED = 'This Clavarium belongs to someone else: your account is not one of its operators.\n';
|
|
116
117
|
|
|
117
|
-
function serve(port, conversations, resources, { gate = () => 'local', originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, repair, capacity, updates } = {}) {
|
|
118
|
+
function serve(port, conversations, resources, { gate = () => 'local', originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, repair, capacity, updates, instance = new Instance() } = {}) {
|
|
119
|
+
/* Its manifest carries the name of this Clavarium, or the one a device chose (instance.mjs). */
|
|
120
|
+
const sendManifest = (response, url) => response.writeHead(200, { ...HEADERS, 'content-type': 'application/manifest+json' }).end(instance.manifest(new URL(url ?? '/', 'http://local').searchParams.get('name') ?? undefined));
|
|
118
121
|
const sendFile = async (response, file) => {
|
|
119
122
|
/* Several files are one, in their order. */
|
|
120
123
|
const body = Array.isArray(file[0]) ? Buffer.concat((await Promise.all(file[0].map((f) => readFile(f.startsWith('/') ? f : ROOT + f)))).flatMap((b) => [b, Buffer.from('\n;\n')])) : await readFile(file[0].startsWith('/') ? file[0] : ROOT + file[0]);
|
|
@@ -139,6 +142,7 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
|
|
|
139
142
|
}
|
|
140
143
|
if (door && pathname.startsWith('/c/')) {
|
|
141
144
|
if (await handleDoor(request, response, door)) return;
|
|
145
|
+
if (pathname === '/c/manifest.webmanifest' && request.method === 'GET') { sendManifest(response, request.url); return; }
|
|
142
146
|
const file = DEVICE_FILES[pathname];
|
|
143
147
|
if (!file || (request.method !== 'GET' && request.method !== 'HEAD')) { response.writeHead(404, HEADERS).end(); return; }
|
|
144
148
|
await sendFile(response, file);
|
|
@@ -149,7 +153,8 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
|
|
|
149
153
|
response.writeHead(403, { ...HEADERS, 'content-type': 'text/plain; charset=utf-8' }).end(REFUSED);
|
|
150
154
|
return;
|
|
151
155
|
}
|
|
152
|
-
if (conversations && await handleApi(request, response, conversations, HEADERS, resources, originOk, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates)) return;
|
|
156
|
+
if (conversations && await handleApi(request, response, conversations, HEADERS, resources, originOk, shortcuts, voice, sharing, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, repair, guide, capacity, updates, instance)) return;
|
|
157
|
+
if (pathname === '/manifest.webmanifest' && request.method === 'GET') { sendManifest(response, request.url); return; }
|
|
153
158
|
const file = FILES[pathname];
|
|
154
159
|
if (!file || (request.method !== 'GET' && request.method !== 'HEAD')) {
|
|
155
160
|
response.writeHead(404, HEADERS).end();
|
|
@@ -179,16 +184,17 @@ const PARLEY_WAIT_MS = 20_000;
|
|
|
179
184
|
/* The product: conversations are VMs. `host` is how the host program is
|
|
180
185
|
reached on this installation, as an argument vector: it is the ONLY thing
|
|
181
186
|
this server runs with more rights than its own. */
|
|
182
|
-
export async function startHome({ port, host, dataDir, mntDir = dataDir, gitReserveBytes, env = process.env, conversations, resources, operators = [], publicOrigin, vaultKey, tools = loadTools(), stopDelayMs, mailReader, gitLocalRemotes = false, gitSsh, answerFetcher, answerEveryMs, wakeEveryMs, alertSend, talkEveryMs, gitEveryMs, shelfEveryMs, shelfFirstMs, healthEveryMs, healthSettleMs, setupPollMs, rereadAfterMs, serverRoomMs, updateState, updateConf }) {
|
|
187
|
+
export async function startHome({ port, host, dataDir, mntDir = dataDir, gitReserveBytes, env = process.env, conversations, resources, operators = [], publicOrigin, vaultKey, tools = loadTools(), stopDelayMs, mailReader, gitLocalRemotes = false, gitSsh, answerFetcher, answerEveryMs, wakeEveryMs, alertSend, talkEveryMs, gitEveryMs, shelfEveryMs, shelfFirstMs, healthEveryMs, healthSettleMs, setupPollMs, rereadAfterMs, serverRoomMs, updateState, updateConf, appName }) {
|
|
183
188
|
/* The data, in the format this code reads, before anything reads it (dataformat.mjs). */
|
|
184
189
|
const migrated = migrateData(dataDir);
|
|
185
190
|
if (migrated.ran.length) console.log(`data: format ${migrated.from} -> ${migrated.to}`);
|
|
186
191
|
const gate = makeGate(operators);
|
|
187
192
|
const originOk = makeOriginCheck(publicOrigin);
|
|
193
|
+
const instance = new Instance({ file: new JsonFile(`${dataDir}/instance.json`, {}), origin: publicOrigin, given: appName });
|
|
188
194
|
let repairTerminal = async () => { throw new Error('not ready'); };
|
|
189
195
|
const asHost = runner(host);
|
|
190
196
|
const vms = new Vms(asHost, host);
|
|
191
|
-
const all = conversations ?? new Conversations({ store: new Store(`${dataDir}/conversations.json`), vms, tools, ...(stopDelayMs === undefined ? {} : { stopDelayMs }) });
|
|
197
|
+
const all = conversations ?? new Conversations({ store: new Store(`${dataDir}/conversations.json`), layout: new JsonFile(`${dataDir}/layout.json`, {}), vms, tools, ...(stopDelayMs === undefined ? {} : { stopDelayMs }) });
|
|
192
198
|
/* Who may do what with what: ONE table, shared by everything below. */
|
|
193
199
|
const grants = new Grants(new Store(`${dataDir}/grants.json`));
|
|
194
200
|
const vault = new Vault(vaultKey ?? `${dataDir}/vault.key`);
|
|
@@ -287,7 +293,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
287
293
|
/* The owner's devices are told when something waits for them: see alerts.mjs. */
|
|
288
294
|
const alerts = new Alerts({ store: new Store(`${dataDir}/alerts.json`), keyFile: `${dataDir}/alerts-keys.json`, subject: publicOrigin, ...(alertSend ? { send: alertSend } : {}) });
|
|
289
295
|
const answerer = new Answerer({ store: new Store(`${dataDir}/answerer.json`), vms, config: answererConfig(env), conversations: all, dataDir, ...(answerFetcher ? { fetcher: answerFetcher } : {}),
|
|
290
|
-
onNeeds: (name, why) => { void alerts.tell({ title: `${name} waits for you`, body: why, url: `/?c=${name}` }); } });
|
|
296
|
+
onNeeds: (name, why) => { void alerts.tell({ title: `${instance.name()}: ${name} waits for you`, body: why, url: `/?c=${name}` }); } });
|
|
291
297
|
const answerTimer = answerer.start(answerEveryMs);
|
|
292
298
|
/* A tool is told to get to work when something arrives, or by the clock: see wakeups.mjs. */
|
|
293
299
|
let bridge;
|
|
@@ -348,7 +354,7 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
348
354
|
say: (name, text) => { try { talk.event(name, { kind: 'machine-problem', text, tone: 'warn', topic: 'Machine' }); } catch { /* its chat may be off */ } } });
|
|
349
355
|
all.room = (ask) => capacity.check(ask);
|
|
350
356
|
const stopWatch = capacity.start();
|
|
351
|
-
const { server, listening } = serve(port, all, folders, { gate, originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, capacity, updates, repair: (name) => repairTerminal(name) });
|
|
357
|
+
const { server, listening } = serve(port, all, folders, { gate, originOk, shortcuts, voice, sharing, door, secrets, network, signin, mailboxes, gitReview, platform, answerer, wakeups, alerts, talk, gitAccess, gitKeeper, shelf, health, setup, guide, capacity, updates, instance, repair: (name) => repairTerminal(name) });
|
|
352
358
|
server.on('close', () => { closing = true; stopShelf(); stopHealth(); stopUpdates(); stopWatch(); clearInterval(mailTimer); clearInterval(answerTimer); clearInterval(wakeTimer); clearInterval(gitTimer); talk.stop(); });
|
|
353
359
|
let closing = false;
|
|
354
360
|
bridge = attachBridge(server, {
|
|
@@ -418,7 +424,9 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
418
424
|
/* Where the shared folders live: $MNT, declared to the host program in
|
|
419
425
|
its own settings, read here so that both agree (see clavarium-host). */
|
|
420
426
|
const mntDir = hostSetting('MNT') || dataDir;
|
|
421
|
-
|
|
427
|
+
/* --name: the name of this Clavarium until one is chosen in Settings (instance.mjs). */
|
|
428
|
+
const name = option(argv, 'name', '') || undefined;
|
|
429
|
+
const server = await startHome({ port, host: host.split(' ').filter(Boolean), dataDir, mntDir, operators, publicOrigin, vaultKey, appName: name });
|
|
422
430
|
console.log(`http://127.0.0.1:${server.address().port}/`);
|
|
423
431
|
} else {
|
|
424
432
|
const command = option(argv, 'command', 'bash').split(' ').filter(Boolean);
|