@jossuealcala/madre 0.6.0 → 0.7.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/CHANGELOG.md +186 -1
- package/README.md +33 -1
- package/docs/SDK.md +173 -6
- package/docs/sdk/connector-module.mjs +186 -0
- package/package.json +6 -2
- package/public/app.js +2342 -506
- package/public/es.js +184 -44
- package/public/index.html +17 -16
- package/public/styles.css +410 -67
- package/src/adapters/claude.mjs +6 -2
- package/src/adapters/gemini.mjs +26 -5
- package/src/adapters/madre.mjs +5 -0
- package/src/dataset.mjs +11 -6
- package/src/distiller.mjs +12 -1
- package/src/lease.mjs +37 -2
- package/src/mcp/smtp-server.mjs +189 -0
- package/src/memory-dashboard.mjs +69 -0
- package/src/memory.mjs +222 -18
- package/src/modules/ahp.mjs +1 -1
- package/src/modules/correo.mjs +107 -0
- package/src/modules/index.mjs +47 -4
- package/src/modules/sdk.mjs +44 -9
- package/src/outbound.mjs +37 -3
- package/src/ports.mjs +23 -3
- package/src/request-guard.mjs +26 -0
- package/src/room/archivist.mjs +2 -1
- package/src/room/prompt.mjs +1 -1
- package/src/room.mjs +59 -9
- package/src/sentinel-errors.mjs +5 -1
- package/src/server.mjs +67 -7
- package/src/smtp.mjs +122 -0
- package/src/vault.mjs +100 -0
- package/src/whats-new.mjs +140 -0
package/src/smtp.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// SMTP, enough of it to send one message over TLS. No dependencies, by the same rule as the rest
|
|
2
|
+
// of MADRE: a library that handles the human's mail password is a library somebody has to read.
|
|
3
|
+
//
|
|
4
|
+
// What it does: open a TLS socket, greet, authenticate, hand over one message, quit. What it does
|
|
5
|
+
// NOT do: STARTTLS on a plaintext port (the session would begin in the clear), attachments,
|
|
6
|
+
// multipart, or any form of queuing. One message, encrypted from the first byte, or nothing.
|
|
7
|
+
//
|
|
8
|
+
// The protocol is a dialogue of three-digit replies. A reply may span lines — `250-` continues,
|
|
9
|
+
// `250 ` ends — and that is the only parsing subtlety here.
|
|
10
|
+
|
|
11
|
+
import { connect } from 'node:tls';
|
|
12
|
+
|
|
13
|
+
export const SMTP_PORT = 465; // implicit TLS. Never 25, never 587 without TLS.
|
|
14
|
+
const CRLF = '\r\n';
|
|
15
|
+
|
|
16
|
+
// A reply ends at the first COMPLETE line whose code is followed by a space rather than a hyphen:
|
|
17
|
+
// `250-` continues, `250 ` ends. The line has to carry its own newline, or a chunk that split
|
|
18
|
+
// mid-line would be read as a finished answer.
|
|
19
|
+
const ENDS = /^(\d{3})(?: [^\r\n]*)?\r\n/m;
|
|
20
|
+
|
|
21
|
+
// An address, strictly enough that it cannot carry a second one. A newline in a header is how a
|
|
22
|
+
// message gets extra recipients nobody typed, so anything with control characters is refused
|
|
23
|
+
// rather than escaped.
|
|
24
|
+
const ADDRESS = /^[^\s<>@,;:"\\\u0000-\u001f]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
|
25
|
+
export const validAddress = (value) => ADDRESS.test(String(value ?? '').trim());
|
|
26
|
+
|
|
27
|
+
// A header value, with the one thing that must never survive: a line break. Folding is not
|
|
28
|
+
// attempted — a long subject travels long — because a header that folds wrongly is a header that
|
|
29
|
+
// injects.
|
|
30
|
+
export const headerSafe = (value) => String(value ?? '').replace(/[\r\n\u0000]+/g, ' ').trim();
|
|
31
|
+
|
|
32
|
+
// Anything non-ASCII in a header has to be encoded or it arrives as mojibake. RFC 2047, base64,
|
|
33
|
+
// whole-value: simpler than word-splitting and correct for the subjects people actually write.
|
|
34
|
+
export function encodeHeader(value) {
|
|
35
|
+
const clean = headerSafe(value);
|
|
36
|
+
if (!clean || /^[\x20-\x7e]*$/.test(clean)) return clean;
|
|
37
|
+
return `=?UTF-8?B?${Buffer.from(clean, 'utf8').toString('base64')}?=`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A line beginning with a dot ends the DATA stage. Doubling it is the protocol's own escape, and
|
|
41
|
+
// forgetting it is how a message gets truncated by its own body.
|
|
42
|
+
export const dotStuff = (body) => String(body ?? '').replace(/\r?\n/g, CRLF).replace(/^\./gm, '..');
|
|
43
|
+
|
|
44
|
+
export function buildMessage({ from, fromName = '', to, subject, body, date = new Date() }) {
|
|
45
|
+
const sender = fromName ? `${encodeHeader(fromName)} <${from}>` : from;
|
|
46
|
+
const headers = [
|
|
47
|
+
`From: ${sender}`,
|
|
48
|
+
`To: ${to.join(', ')}`,
|
|
49
|
+
`Subject: ${encodeHeader(subject)}`,
|
|
50
|
+
`Date: ${date.toUTCString()}`,
|
|
51
|
+
`Message-ID: <${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}@${from.split('@')[1]}>`,
|
|
52
|
+
'MIME-Version: 1.0',
|
|
53
|
+
'Content-Type: text/plain; charset=utf-8',
|
|
54
|
+
'Content-Transfer-Encoding: 8bit',
|
|
55
|
+
];
|
|
56
|
+
return `${headers.join(CRLF)}${CRLF}${CRLF}${dotStuff(body)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// One conversation. Every step says what it expects, so a refusal names the step it failed at
|
|
60
|
+
// instead of surfacing as a bare three-digit code.
|
|
61
|
+
export async function sendMail({ host = 'smtp.gmail.com', port = SMTP_PORT, user, password, from, fromName = '', to, subject, body, timeoutMs = 20000, connectImpl = connect, date = undefined }) {
|
|
62
|
+
const recipients = (Array.isArray(to) ? to : [to]).map((one) => String(one ?? '').trim()).filter(Boolean);
|
|
63
|
+
if (!validAddress(from)) throw Object.assign(new Error('The sending address is not an address.'), { code: 'BAD_FROM' });
|
|
64
|
+
if (!recipients.length) throw Object.assign(new Error('Say who it goes to.'), { code: 'NO_RECIPIENT' });
|
|
65
|
+
for (const one of recipients) {
|
|
66
|
+
if (!validAddress(one)) throw Object.assign(new Error(`That is not an address: ${one}`), { code: 'BAD_RECIPIENT' });
|
|
67
|
+
}
|
|
68
|
+
if (!password) throw Object.assign(new Error('There is no app password in the vault for this connector.'), { code: 'NO_SECRET' });
|
|
69
|
+
|
|
70
|
+
const socket = connectImpl({ host, port, servername: host });
|
|
71
|
+
socket.setEncoding('utf8');
|
|
72
|
+
socket.setTimeout(timeoutMs);
|
|
73
|
+
|
|
74
|
+
let buffer = '';
|
|
75
|
+
let waiting = null;
|
|
76
|
+
const fail = (error) => { if (waiting) { const { reject } = waiting; waiting = null; reject(error); } };
|
|
77
|
+
socket.on('error', (error) => fail(Object.assign(error, { code: error.code ?? 'SMTP_SOCKET' })));
|
|
78
|
+
socket.on('timeout', () => { socket.destroy(); fail(Object.assign(new Error(`${host} stopped answering.`), { code: 'SMTP_TIMEOUT' })); });
|
|
79
|
+
socket.on('close', () => fail(Object.assign(new Error(`${host} closed the connection.`), { code: 'SMTP_CLOSED' })));
|
|
80
|
+
socket.on('data', (chunk) => {
|
|
81
|
+
buffer += chunk;
|
|
82
|
+
const done = buffer.match(ENDS);
|
|
83
|
+
if (!done || !waiting) return;
|
|
84
|
+
const text = buffer.trim();
|
|
85
|
+
buffer = '';
|
|
86
|
+
const { resolve } = waiting;
|
|
87
|
+
waiting = null;
|
|
88
|
+
resolve({ code: Number(done[1]), text });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const read = () => new Promise((resolve, reject) => { waiting = { resolve, reject }; });
|
|
92
|
+
const say = async (line, expect, step) => {
|
|
93
|
+
if (line !== null) socket.write(`${line}${CRLF}`);
|
|
94
|
+
const reply = await read();
|
|
95
|
+
if (!expect.includes(reply.code)) {
|
|
96
|
+
socket.destroy();
|
|
97
|
+
throw Object.assign(new Error(`${host} refused at ${step}: ${reply.text.split(/\r?\n/)[0]}`), { code: `SMTP_${reply.code}`, step, reply: reply.text });
|
|
98
|
+
}
|
|
99
|
+
return reply;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await say(null, [220], 'greeting');
|
|
104
|
+
await say(`EHLO ${from.split('@')[1]}`, [250], 'EHLO');
|
|
105
|
+
// AUTH LOGIN: the user and the password, each base64, each on its own line. Chosen over
|
|
106
|
+
// PLAIN because it is what Gmail documents for app passwords.
|
|
107
|
+
await say('AUTH LOGIN', [334], 'AUTH');
|
|
108
|
+
await say(Buffer.from(user ?? from, 'utf8').toString('base64'), [334], 'user');
|
|
109
|
+
await say(Buffer.from(password, 'utf8').toString('base64'), [235], 'password');
|
|
110
|
+
await say(`MAIL FROM:<${from}>`, [250], 'MAIL FROM');
|
|
111
|
+
for (const one of recipients) await say(`RCPT TO:<${one}>`, [250, 251], 'RCPT TO');
|
|
112
|
+
await say('DATA', [354], 'DATA');
|
|
113
|
+
const message = buildMessage({ from, fromName, to: recipients, subject, body, ...(date ? { date } : {}) });
|
|
114
|
+
const accepted = await say(`${message}${CRLF}.`, [250], 'message');
|
|
115
|
+
socket.write(`QUIT${CRLF}`);
|
|
116
|
+
socket.end();
|
|
117
|
+
return { ok: true, to: recipients, bytes: Buffer.byteLength(message, 'utf8'), reply: accepted.text.split(/\r?\n/)[0] };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
socket.destroy();
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/vault.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The vault: where a connector's secret lives.
|
|
2
|
+
//
|
|
3
|
+
// Until now MADRE held none. A key for Gemini or OpenCode is written where THAT CLI looks for it
|
|
4
|
+
// and MADRE keeps no copy — which is a promise worth having, and it only works because there is a
|
|
5
|
+
// CLI to hand it to. A connector has none: the module itself is what talks to the service, so if
|
|
6
|
+
// MADRE does not hold the secret, nobody does.
|
|
7
|
+
//
|
|
8
|
+
// So it holds them, and the terms are these:
|
|
9
|
+
//
|
|
10
|
+
// · One file per module, under `<stateRoot>/credentials/`, `0600` inside a `0700` directory.
|
|
11
|
+
// · Never in the config, never in the ledger, never in a log, never in a prompt. The ledger
|
|
12
|
+
// records that a secret was SET and under which name — never the value, the same way privacy
|
|
13
|
+
// counts how many terms it guards and never which.
|
|
14
|
+
// · Written only from this computer. The server refuses a secret arriving over the network.
|
|
15
|
+
// · `summary()` is what every other part of MADRE is allowed to see: names and sizes, no values.
|
|
16
|
+
//
|
|
17
|
+
// Nothing here reads a secret into a turn. A module asks for its own and hands it to the server it
|
|
18
|
+
// spawns, through that process's environment — which is how a CLI gets one too.
|
|
19
|
+
|
|
20
|
+
import { chmod, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
const OWNER_ONLY = 0o600;
|
|
24
|
+
const OWNER_DIR = 0o700;
|
|
25
|
+
const MAX_SECRET = 8000;
|
|
26
|
+
|
|
27
|
+
export const vaultDir = (stateRoot) => join(stateRoot, 'credentials');
|
|
28
|
+
const fileFor = (stateRoot, moduleId) => join(vaultDir(stateRoot), `${moduleId}.json`);
|
|
29
|
+
|
|
30
|
+
// A module id and a secret name are both file-ish and both come from outside: anything that is
|
|
31
|
+
// not plainly a name is refused rather than sanitised, because a sanitised path is a path that
|
|
32
|
+
// looked wrong and was used anyway.
|
|
33
|
+
const NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
34
|
+
export const validName = (value) => NAME.test(String(value ?? ''));
|
|
35
|
+
|
|
36
|
+
async function readAll(stateRoot, moduleId) {
|
|
37
|
+
try { return JSON.parse(await readFile(fileFor(stateRoot, moduleId), 'utf8')); }
|
|
38
|
+
catch { return {}; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// What a module put away, for the module itself. The only function in MADRE that returns a value.
|
|
42
|
+
export async function readSecret(stateRoot, moduleId, name) {
|
|
43
|
+
if (!validName(moduleId) || !validName(name)) return null;
|
|
44
|
+
const secret = (await readAll(stateRoot, moduleId))[name];
|
|
45
|
+
return typeof secret === 'string' && secret ? secret : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function writeSecret(stateRoot, moduleId, name, value) {
|
|
49
|
+
if (!validName(moduleId)) return { ok: false, error: 'That is not a module id.' };
|
|
50
|
+
if (!validName(name)) return { ok: false, error: 'That is not a secret name.' };
|
|
51
|
+
const secret = String(value ?? '').trim();
|
|
52
|
+
if (!secret) return { ok: false, error: 'Paste the secret first.' };
|
|
53
|
+
if (/\s/.test(secret)) return { ok: false, error: 'That does not look like a secret: it has spaces or line breaks.' };
|
|
54
|
+
if (secret.length > MAX_SECRET) return { ok: false, error: 'That is too long to be a secret.' };
|
|
55
|
+
const dir = vaultDir(stateRoot);
|
|
56
|
+
await mkdir(dir, { recursive: true, mode: OWNER_DIR });
|
|
57
|
+
// An older vault directory may predate this mode; set it either way rather than trust the
|
|
58
|
+
// creation flags of a directory that was already there.
|
|
59
|
+
await chmod(dir, OWNER_DIR).catch(() => {});
|
|
60
|
+
const all = await readAll(stateRoot, moduleId);
|
|
61
|
+
all[name] = secret;
|
|
62
|
+
await writeFile(fileFor(stateRoot, moduleId), JSON.stringify(all), { mode: OWNER_ONLY });
|
|
63
|
+
await chmod(fileFor(stateRoot, moduleId), OWNER_ONLY).catch(() => {});
|
|
64
|
+
return { ok: true, name, bytes: secret.length };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function forgetSecret(stateRoot, moduleId, name = null) {
|
|
68
|
+
if (!validName(moduleId)) return { ok: false, error: 'That is not a module id.' };
|
|
69
|
+
if (name === null) {
|
|
70
|
+
await rm(fileFor(stateRoot, moduleId), { force: true });
|
|
71
|
+
return { ok: true, forgot: 'all' };
|
|
72
|
+
}
|
|
73
|
+
if (!validName(name)) return { ok: false, error: 'That is not a secret name.' };
|
|
74
|
+
const all = await readAll(stateRoot, moduleId);
|
|
75
|
+
if (!(name in all)) return { ok: true, forgot: null };
|
|
76
|
+
delete all[name];
|
|
77
|
+
if (Object.keys(all).length) await writeFile(fileFor(stateRoot, moduleId), JSON.stringify(all), { mode: OWNER_ONLY });
|
|
78
|
+
else await rm(fileFor(stateRoot, moduleId), { force: true });
|
|
79
|
+
return { ok: true, forgot: name };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// What the room, the cards and the ledger may know: that a secret is there, what it is called and
|
|
83
|
+
// how long it is. Never the secret. This is the only shape that leaves this file for anyone but
|
|
84
|
+
// the module that owns it.
|
|
85
|
+
export async function summary(stateRoot, moduleId = null) {
|
|
86
|
+
let files = [];
|
|
87
|
+
try { files = (await readdir(vaultDir(stateRoot))).filter((name) => name.endsWith('.json')); }
|
|
88
|
+
catch { return []; }
|
|
89
|
+
const held = [];
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const id = file.replace(/\.json$/, '');
|
|
92
|
+
if (moduleId && id !== moduleId) continue;
|
|
93
|
+
const all = await readAll(stateRoot, id);
|
|
94
|
+
for (const [name, secret] of Object.entries(all)) {
|
|
95
|
+
if (typeof secret !== 'string') continue;
|
|
96
|
+
held.push({ module: id, name, bytes: secret.length });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return held.sort((a, b) => `${a.module}${a.name}`.localeCompare(`${b.module}${b.name}`));
|
|
100
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// What a version brings, said to the person who just got it.
|
|
2
|
+
//
|
|
3
|
+
// After an update the room opens once on a sheet of what is new: a few things, each said plainly,
|
|
4
|
+
// each with where to find it, and a way to read every change. Once per version, for everyone who
|
|
5
|
+
// came from an older one; never on a first install, where the tour does that job.
|
|
6
|
+
//
|
|
7
|
+
// Whether it has been seen lives in ~/.pulse/whats-new.json and not in the browser: the page's
|
|
8
|
+
// storage belongs to one port, and the same person opening the room on another port has still
|
|
9
|
+
// seen it. Nothing here touches the network; the link to every change is the release page the
|
|
10
|
+
// room already knows (updates.mjs).
|
|
11
|
+
//
|
|
12
|
+
// The notes are written by hand, version by version, in both languages the room speaks. A minor
|
|
13
|
+
// version cannot be closed without them (scripts/release.mjs); a patch may ship without, and then
|
|
14
|
+
// nothing opens.
|
|
15
|
+
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { compareVersions } from './updates.mjs';
|
|
20
|
+
|
|
21
|
+
const FILE = 'whats-new.json';
|
|
22
|
+
|
|
23
|
+
// `icon` names one of the room's 8-bit icons. `where` says where it lives, in the words the
|
|
24
|
+
// screens use. `more` is the one thing nobody is told how to find.
|
|
25
|
+
export const NOTES = {
|
|
26
|
+
'0.7.0': {
|
|
27
|
+
intro: {
|
|
28
|
+
es: 'Esta versión le da forma a la memoria de la sala: ahora la ves en tres dimensiones, la lees en un tablero y cada memoria te dice cómo está.',
|
|
29
|
+
en: 'This version gives the room’s memory a shape: you see it in three dimensions, read it on a dashboard, and every memory tells you how it is doing.',
|
|
30
|
+
},
|
|
31
|
+
items: [
|
|
32
|
+
{
|
|
33
|
+
icon: 'orbit',
|
|
34
|
+
title: { es: 'NOSTROMO en tres dimensiones', en: 'NOSTROMO in three dimensions' },
|
|
35
|
+
body: {
|
|
36
|
+
es: 'El archivo de memorias tiene forma de cerebro y lo giras con el ratón. Al elegir una memoria, la sala la pone de frente, la fija con una mira y atenúa todo lo demás.',
|
|
37
|
+
en: 'The memory archive is shaped like a brain and you turn it with the mouse. Choose a memory and the room brings it to face you, locks a sight on it and dims everything else.',
|
|
38
|
+
},
|
|
39
|
+
where: { es: 'MU/TH/UR › NOSTROMO · arrastra para girar, Shift + arrastra para moverte', en: 'MU/TH/UR › NOSTROMO · drag to turn, Shift + drag to move' },
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
icon: 'star',
|
|
43
|
+
title: { es: 'Cada memoria es una estrella', en: 'Every memory is a star' },
|
|
44
|
+
body: {
|
|
45
|
+
es: 'Su brillo dice cuánto la usa la sala; su superficie, si la confirmaste, si nadie la ha juzgado, si resultó falsa o si se está enfriando. En el centro, MOTHER: magma vivo.',
|
|
46
|
+
en: 'Its brightness says how much the room relies on it; its surface, whether you confirmed it, nobody has judged it yet, it turned out false, or it is cooling. At the centre, MOTHER: living magma.',
|
|
47
|
+
},
|
|
48
|
+
where: { es: 'MU/TH/UR › NOSTROMO', en: 'MU/TH/UR › NOSTROMO' },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
icon: 'bars',
|
|
52
|
+
title: { es: 'Un tablero para la memoria', en: 'A dashboard for memory' },
|
|
53
|
+
body: {
|
|
54
|
+
es: 'Lo que el archivo hizo día a día, de qué está hecho, qué tan bien contesta y cuál es el siguiente paso, en una sola pantalla.',
|
|
55
|
+
en: 'What the archive did day by day, what it is made of, how well it answers and what to do next, on a single screen.',
|
|
56
|
+
},
|
|
57
|
+
where: { es: 'MU/TH/UR › MEMORIA', en: 'MU/TH/UR › MEMORY' },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
icon: 'chip',
|
|
61
|
+
title: { es: 'Ideas para tu propio módulo', en: 'Ideas for a module of your own' },
|
|
62
|
+
body: {
|
|
63
|
+
es: 'Escribe /module y elige entre ocho ideas listas para editar: comandos, conectores con tus servicios y fichas propias. Un agente lo escribe y tú lo revisas antes de instalarlo.',
|
|
64
|
+
en: 'Type /module and pick one of eight ideas ready to edit: commands, connectors to your services and cards of your own. An agent writes it and you review it before it is installed.',
|
|
65
|
+
},
|
|
66
|
+
where: { es: 'En la caja de texto · escribe /module', en: 'In the message box · type /module' },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
icon: 'stop',
|
|
70
|
+
title: { es: 'Detén una respuesta, no la sala', en: 'Stop one reply, not the room' },
|
|
71
|
+
body: {
|
|
72
|
+
es: 'Un botón DETENER detiene a un solo agente a mitad de su respuesta; los demás siguen trabajando.',
|
|
73
|
+
en: 'A STOP button halts one agent in the middle of its reply; the others keep working.',
|
|
74
|
+
},
|
|
75
|
+
where: { es: 'En la fila del turno en curso, junto a los segundos', en: 'On the row of the turn in progress, next to the seconds' },
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
icon: 'lock',
|
|
79
|
+
title: { es: 'Más protección desde el navegador', en: 'More protection from the browser' },
|
|
80
|
+
body: {
|
|
81
|
+
es: 'Ninguna página web que tengas abierta puede ya instalar un módulo en tu sala ni leer de ella.',
|
|
82
|
+
en: 'No web page you have open can install a module in your room or read from it anymore.',
|
|
83
|
+
},
|
|
84
|
+
where: { es: 'Siempre activo; no tienes que hacer nada', en: 'Always on; there is nothing to set up' },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
more: {
|
|
88
|
+
icon: 'ufo',
|
|
89
|
+
title: { es: 'Una cosa más…', en: 'One more thing…' },
|
|
90
|
+
body: {
|
|
91
|
+
es: 'Alguien más viaja a bordo. No está en ningún menú ni en ningún manual. Dicen que solo baja cuando llamas a la nave por su nombre, y por nada más.',
|
|
92
|
+
en: 'Someone else travels on board. Not in any menu, not in any manual. They say it only comes down when you call the ship by its name, and nothing else.',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const notesFor = (version) => NOTES[version] ?? null;
|
|
99
|
+
|
|
100
|
+
// Whether this person should see the sheet for this version. Someone who has seen an older one,
|
|
101
|
+
// or who used the room before the sheet existed, sees it once; a first install never does.
|
|
102
|
+
export function shouldShow({ version, seen = null, returning = false }) {
|
|
103
|
+
if (!notesFor(version)) return false;
|
|
104
|
+
if (seen) return compareVersions(version, seen) > 0;
|
|
105
|
+
return Boolean(returning);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function readSeen(root) {
|
|
109
|
+
try {
|
|
110
|
+
const value = JSON.parse(await readFile(join(root, FILE), 'utf8'))?.seen;
|
|
111
|
+
return typeof value === 'string' ? value : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function markSeen(root, version) {
|
|
118
|
+
await mkdir(root, { recursive: true });
|
|
119
|
+
await writeFile(join(root, FILE), `${JSON.stringify({ seen: version }, null, 2)}\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read at start-up, before this run creates anything: a room that already had rooms used MADRE
|
|
123
|
+
// before. A first install has nothing to catch up on, so it is marked as seen and never shown.
|
|
124
|
+
export async function bootWhatsNew({ root, version }) {
|
|
125
|
+
const seen = await readSeen(root);
|
|
126
|
+
if (seen) return { seen, returning: true };
|
|
127
|
+
let returning = false;
|
|
128
|
+
if (existsSync(join(root, 'rooms'))) {
|
|
129
|
+
try { returning = (await readdir(join(root, 'rooms'))).length > 0; } catch { returning = false; }
|
|
130
|
+
}
|
|
131
|
+
if (!returning) await markSeen(root, version);
|
|
132
|
+
return { seen: returning ? null : version, returning };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// `any`: the sheet for this version whether or not it was seen, for when it is asked for.
|
|
136
|
+
export async function whatsNew({ root, version, returning, any = false }) {
|
|
137
|
+
const seen = await readSeen(root);
|
|
138
|
+
const show = shouldShow({ version, seen, returning });
|
|
139
|
+
return { version, show, notes: show || any ? notesFor(version) : null };
|
|
140
|
+
}
|