whose-turn 0.0.0-stage → 1.0.0-alpha.3
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 +29 -0
- package/LICENSE +21 -0
- package/README.md +244 -2
- package/config.example.json +144 -0
- package/connectors/demo.mjs +146 -0
- package/connectors/github.mjs +316 -0
- package/connectors/index.mjs +38 -0
- package/connectors/jira.mjs +155 -0
- package/connectors/linear.mjs +113 -0
- package/dist/assets/_plugin-vue_export-helper-BKveAJb2.css +1 -0
- package/dist/assets/_plugin-vue_export-helper-DHr5RHTx.js +18 -0
- package/dist/assets/bem-COKTjsky.js +1 -0
- package/dist/assets/index-CuyWocyb.js +3 -0
- package/dist/assets/index-p8D7awn0.css +1 -0
- package/dist/assets/report-BtGnm9ax.js +1 -0
- package/dist/assets/report-xVOo7qip.css +1 -0
- package/dist/assets/scheme-DJ3WNw60.js +1 -0
- package/dist/assets/sheet-CBYGM-FG.js +1 -0
- package/dist/assets/sheet-DYpaJaXO.css +1 -0
- package/dist/index.html +19 -0
- package/dist/report.html +16 -0
- package/dist/sheet.html +16 -0
- package/dist/vendor/popper.min.js +6 -0
- package/dist/vendor/tippy-bundle.umd.min.js +2 -0
- package/install.sh +69 -0
- package/lib/api.mjs +406 -0
- package/lib/backoff.mjs +2 -0
- package/lib/compose.mjs +67 -0
- package/lib/config.mjs +290 -0
- package/lib/default-table.mjs +58 -0
- package/lib/errors.mjs +55 -0
- package/lib/facts.mjs +128 -0
- package/lib/feedback.mjs +148 -0
- package/lib/handoff.mjs +127 -0
- package/lib/health.mjs +35 -0
- package/lib/http-client.mjs +44 -0
- package/lib/inbox.mjs +60 -0
- package/lib/linear-api.mjs +12 -0
- package/lib/notify.mjs +36 -0
- package/lib/palette.mjs +213 -0
- package/lib/platform/credential.ps1 +84 -0
- package/lib/registry.mjs +61 -0
- package/lib/rules.mjs +241 -0
- package/lib/secrets.mjs +69 -0
- package/lib/static.mjs +36 -0
- package/lib/store.mjs +61 -0
- package/lib/table.mjs +148 -0
- package/lib/text.mjs +46 -0
- package/lib/token-cache.mjs +12 -0
- package/lib/update.mjs +134 -0
- package/lib/window.mjs +33 -0
- package/lib/workspace.mjs +294 -0
- package/open.sh +40 -0
- package/package.json +67 -4
- package/server.mjs +96 -0
package/lib/static.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Serves the built window from dist/ (ADR 0001): hashed assets cached by the browser for good, the pages never cached, files read once per build. A checkout without a build gets a plain notice. */
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TYPES = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.map': 'application/json' };
|
|
6
|
+
const PAGES = { '/': 'index.html', '/report': 'report.html', '/sheet': 'sheet.html' };
|
|
7
|
+
const UNBUILT = Buffer.from('<!doctype html><meta charset="utf-8"><title>Whose Turn</title><p style="font: 15px system-ui; margin: 40px">The window is not built. Run <code>npm run build</code> in the whose-turn directory, then reload. The API on this port works without it.</p>');
|
|
8
|
+
|
|
9
|
+
export const createStatic = (distDir) => {
|
|
10
|
+
const cache = new Map();
|
|
11
|
+
const read = async (relative) => {
|
|
12
|
+
const file = join(distDir, relative);
|
|
13
|
+
const { mtimeMs } = await stat(file);
|
|
14
|
+
const hit = cache.get(relative);
|
|
15
|
+
if (hit && hit.mtimeMs === mtimeMs) {
|
|
16
|
+
return hit.body;
|
|
17
|
+
}
|
|
18
|
+
const body = await readFile(file);
|
|
19
|
+
cache.set(relative, { mtimeMs, body });
|
|
20
|
+
return body;
|
|
21
|
+
};
|
|
22
|
+
/** The file for a path, with its type and cache header, or null when the path is not a window file; the unbuilt notice stands in for a missing page. */
|
|
23
|
+
return async (pathname) => {
|
|
24
|
+
const relative = PAGES[pathname] || pathname.replace(/^\/+/, '');
|
|
25
|
+
const type = TYPES[extname(relative)];
|
|
26
|
+
if (!type || relative.split('/').includes('..')) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const page = Boolean(PAGES[pathname]);
|
|
30
|
+
try {
|
|
31
|
+
return { body: await read(relative), type, cacheControl: page ? 'no-store' : relative.startsWith('assets/') ? 'public, max-age=31536000, immutable' : 'public, max-age=3600' };
|
|
32
|
+
} catch {
|
|
33
|
+
return page ? { body: UNBUILT, type: 'text/html', cacheControl: 'no-store' } : null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
};
|
package/lib/store.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** One workspace's files on disk: state.json (last poll per source), marks.json (seen, handled, notified, health alerts already announced), todos.json and cache.json (connector caches that are data). */
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, sep } from 'node:path';
|
|
4
|
+
import { DAY } from './text.mjs';
|
|
5
|
+
|
|
6
|
+
export const readJson = (path, fallback) => {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const writeJson = (path, value) => {
|
|
15
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(value));
|
|
16
|
+
renameSync(`${path}.tmp`, path);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const CACHE_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
const encodeCache = (cache, keys) => Object.fromEntries(keys.filter((key) => cache[key] instanceof Map).map((key) => [key, [...cache[key]]]));
|
|
22
|
+
|
|
23
|
+
const decodeCache = (saved) => Object.fromEntries(Object.entries(saved || {}).filter(([, entries]) => Array.isArray(entries)).map(([key, entries]) => [key, new Map(entries)]));
|
|
24
|
+
|
|
25
|
+
export const openStore = (dataDir, workspaceId) => {
|
|
26
|
+
const root = resolve(dataDir, 'workspaces');
|
|
27
|
+
const dir = resolve(root, workspaceId);
|
|
28
|
+
if (!dir.startsWith(root + sep)) {
|
|
29
|
+
throw new Error(`Workspace id "${workspaceId}" resolves outside the data directory.`);
|
|
30
|
+
}
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
const files = { state: join(dir, 'state.json'), marks: join(dir, 'marks.json'), todos: join(dir, 'todos.json'), cache: join(dir, 'cache.json') };
|
|
33
|
+
const state = { version: 0, errors: {}, polled_at: '', fails: {}, sources: {}, events: [], ...readJson(files.state, {}) };
|
|
34
|
+
const marks = { seen: [], base: null, handled: {}, notified: [], notified_seeded: false, health_alerted: [], ...readJson(files.marks, {}) };
|
|
35
|
+
if (!marks.base) {
|
|
36
|
+
marks.base = Date.now() - 3 * DAY;
|
|
37
|
+
}
|
|
38
|
+
const todos = readJson(files.todos, []);
|
|
39
|
+
const saved = readJson(files.cache, {});
|
|
40
|
+
const savedCaches = saved.version === CACHE_VERSION && saved.entries ? saved.entries : {};
|
|
41
|
+
let savedText = '';
|
|
42
|
+
return {
|
|
43
|
+
state,
|
|
44
|
+
marks,
|
|
45
|
+
todos,
|
|
46
|
+
saveState: () => writeJson(files.state, state),
|
|
47
|
+
saveMarks: () => writeJson(files.marks, marks),
|
|
48
|
+
saveTodos: () => writeJson(files.todos, todos),
|
|
49
|
+
loadCache: (entryId) => decodeCache(savedCaches[entryId]),
|
|
50
|
+
/** keysByEntry: entry id → the cache keys that are data; anything else (the token) never reaches disk. */
|
|
51
|
+
saveCaches: (caches, keysByEntry) => {
|
|
52
|
+
const entries = Object.fromEntries(Object.entries(keysByEntry).map(([entryId, keys]) => [entryId, encodeCache(caches[entryId] || {}, keys)]));
|
|
53
|
+
const file = { version: CACHE_VERSION, entries };
|
|
54
|
+
const text = JSON.stringify(file);
|
|
55
|
+
if (text !== savedText) {
|
|
56
|
+
savedText = text;
|
|
57
|
+
writeJson(files.cache, file);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
};
|
package/lib/table.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** The rules table: validating rows, expanding the "default" marker, finding the first row whose conditions hold, filling {fact} placeholders. See docs/mechanisms/rules-table.md. */
|
|
2
|
+
import { FACTS } from './facts.mjs';
|
|
3
|
+
|
|
4
|
+
/** `then.whose` values: the label the row shows, and the feed group it forces (none keeps the seed row's group). */
|
|
5
|
+
export const WHOSE = {
|
|
6
|
+
you: { label: 'You', group: 'you' },
|
|
7
|
+
reviewer: { label: 'Reviewer', group: null },
|
|
8
|
+
qa: { label: 'QA', group: null },
|
|
9
|
+
them: { label: null, group: null },
|
|
10
|
+
hidden: { label: null, group: 'gone' },
|
|
11
|
+
};
|
|
12
|
+
const THEN_KEYS = ['whose', 'next', 'why', 'rank', 'started', 'hold'];
|
|
13
|
+
const DEFAULT_MARKER = 'default';
|
|
14
|
+
|
|
15
|
+
const present = (value) => value !== null && value !== undefined;
|
|
16
|
+
const NUMBER_OPERATORS = {
|
|
17
|
+
gt: (value, operand) => present(value) && value > operand,
|
|
18
|
+
gte: (value, operand) => present(value) && value >= operand,
|
|
19
|
+
lt: (value, operand) => present(value) && value < operand,
|
|
20
|
+
lte: (value, operand) => present(value) && value <= operand,
|
|
21
|
+
};
|
|
22
|
+
const OPERATORS = {
|
|
23
|
+
...NUMBER_OPERATORS,
|
|
24
|
+
prefix: (value, operand) => present(value) && String(value).startsWith(operand),
|
|
25
|
+
present: (value, operand) => present(value) === operand,
|
|
26
|
+
};
|
|
27
|
+
const PLACEHOLDER = /\{([\w.]+)\}/g;
|
|
28
|
+
|
|
29
|
+
const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
|
|
31
|
+
const conditionHolds = (value, condition) => {
|
|
32
|
+
if (Array.isArray(condition)) {
|
|
33
|
+
return condition.includes(value);
|
|
34
|
+
}
|
|
35
|
+
if (isPlainObject(condition)) {
|
|
36
|
+
return Object.entries(condition).every(([operator, operand]) => OPERATORS[operator](value, operand));
|
|
37
|
+
}
|
|
38
|
+
return value === condition;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const firstMatch = (table, facts) => table.find((row) => Object.entries(row.when).every(([fact, condition]) => conditionHolds(facts[fact], condition)));
|
|
42
|
+
|
|
43
|
+
/** Replaces each {fact} with its value; a missing fact becomes empty text, an unknown name is left as written. */
|
|
44
|
+
export const fillText = (text, facts) => String(text || '').replace(PLACEHOLDER, (placeholder, name) => (name in FACTS ? String(facts[name] ?? '') : placeholder));
|
|
45
|
+
|
|
46
|
+
export const expandTable = (table, defaults) => (Array.isArray(table) ? table.flatMap((row) => (row === DEFAULT_MARKER ? defaults : [row])) : defaults);
|
|
47
|
+
|
|
48
|
+
const closedValues = (name, groups) => {
|
|
49
|
+
const values = FACTS[name].values;
|
|
50
|
+
return values === 'status groups' ? groups : values;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const valueProblem = (name, value, groups) => {
|
|
54
|
+
const fact = FACTS[name];
|
|
55
|
+
if (typeof value !== fact.type) {
|
|
56
|
+
return `"${name}" is a ${fact.type}, got ${JSON.stringify(value)}`;
|
|
57
|
+
}
|
|
58
|
+
const values = closedValues(name, groups);
|
|
59
|
+
return values && !values.includes(value) ? `"${name}" is one of ${values.join(', ')}, got ${JSON.stringify(value)}` : null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const operatorProblem = (name, operator, operand) => {
|
|
63
|
+
const type = FACTS[name].type;
|
|
64
|
+
if (operator in NUMBER_OPERATORS) {
|
|
65
|
+
return type === 'number' && typeof operand === 'number' ? null : `"${operator}" needs a number fact and a number, "${name}" is a ${type}`;
|
|
66
|
+
}
|
|
67
|
+
if (operator === 'prefix') {
|
|
68
|
+
return type === 'string' && typeof operand === 'string' ? null : `"prefix" needs a text fact and a text, "${name}" is a ${type}`;
|
|
69
|
+
}
|
|
70
|
+
if (operator === 'present') {
|
|
71
|
+
return typeof operand === 'boolean' ? null : `"present" needs true or false, got ${JSON.stringify(operand)}`;
|
|
72
|
+
}
|
|
73
|
+
return `unknown operator "${operator}" on "${name}"; use gt, gte, lt, lte, prefix or present`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const conditionProblems = (name, condition, groups) => {
|
|
77
|
+
if (!(name in FACTS)) {
|
|
78
|
+
return [`unknown fact "${name}"; GET /api/config lists the facts`];
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(condition)) {
|
|
81
|
+
return condition.length ? condition.map((value) => valueProblem(name, value, groups)).filter(Boolean) : [`the list for "${name}" is empty`];
|
|
82
|
+
}
|
|
83
|
+
if (isPlainObject(condition)) {
|
|
84
|
+
const entries = Object.entries(condition);
|
|
85
|
+
return entries.length ? entries.map(([operator, operand]) => operatorProblem(name, operator, operand)).filter(Boolean) : [`the condition on "${name}" is an empty object`];
|
|
86
|
+
}
|
|
87
|
+
const problem = valueProblem(name, condition, groups);
|
|
88
|
+
return problem ? [problem] : [];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const placeholderProblems = (text) => [...String(text).matchAll(PLACEHOLDER)].filter(([, name]) => !(name in FACTS)).map(([, name]) => `unknown fact "{${name}}" in the text`);
|
|
92
|
+
|
|
93
|
+
const thenProblems = (then) => {
|
|
94
|
+
if (!isPlainObject(then)) {
|
|
95
|
+
return ['"then" must be an object { whose, next, why?, rank?, started?, hold? }'];
|
|
96
|
+
}
|
|
97
|
+
const problems = Object.keys(then).filter((key) => !THEN_KEYS.includes(key)).map((key) => `unknown key "then.${key}"`);
|
|
98
|
+
if (!(then.whose in WHOSE)) {
|
|
99
|
+
problems.push(`"then.whose" is one of ${Object.keys(WHOSE).join(', ')}, got ${JSON.stringify(then.whose)}`);
|
|
100
|
+
}
|
|
101
|
+
for (const key of ['started', 'hold']) {
|
|
102
|
+
if (then[key] !== undefined && typeof then[key] !== 'boolean') {
|
|
103
|
+
problems.push(`"then.${key}" must be true or false`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (then.rank !== undefined && !(Number.isInteger(then.rank) && then.rank >= 0)) {
|
|
107
|
+
problems.push('"then.rank" must be a whole number, 0 or more');
|
|
108
|
+
}
|
|
109
|
+
for (const key of ['next', 'why']) {
|
|
110
|
+
if (then[key] !== undefined && typeof then[key] !== 'string') {
|
|
111
|
+
problems.push(`"then.${key}" must be text`);
|
|
112
|
+
} else if (then[key]) {
|
|
113
|
+
problems.push(...placeholderProblems(then[key]));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (then.whose !== 'hidden' && !then.next) {
|
|
117
|
+
problems.push('"then.next" is required unless whose is hidden');
|
|
118
|
+
}
|
|
119
|
+
return problems;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const rowProblems = (row, groups) => {
|
|
123
|
+
if (!isPlainObject(row)) {
|
|
124
|
+
return [`a row is { when, then } or the text "${DEFAULT_MARKER}"`];
|
|
125
|
+
}
|
|
126
|
+
const problems = Object.keys(row).filter((key) => !['when', 'then'].includes(key)).map((key) => `unknown key "${key}"`);
|
|
127
|
+
if (!isPlainObject(row.when)) {
|
|
128
|
+
problems.push('"when" must be an object of { fact: condition }');
|
|
129
|
+
} else {
|
|
130
|
+
problems.push(...Object.entries(row.when).flatMap(([name, condition]) => conditionProblems(name, condition, groups)));
|
|
131
|
+
}
|
|
132
|
+
return [...problems, ...thenProblems(row.then)];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** What is wrong with a workspace table, one line per problem, each naming its row (1-based); empty when the table is fine or absent. */
|
|
136
|
+
export const tableProblems = (table, groups) => {
|
|
137
|
+
if (table === undefined) {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
if (!Array.isArray(table)) {
|
|
141
|
+
return ['rules.table must be a list of rows'];
|
|
142
|
+
}
|
|
143
|
+
const problems = table.flatMap((row, index) => (row === DEFAULT_MARKER ? [] : rowProblems(row, groups).map((problem) => `rules.table row ${index + 1}: ${problem}`)));
|
|
144
|
+
if (table.filter((row) => row === DEFAULT_MARKER).length > 1) {
|
|
145
|
+
problems.push(`rules.table: "${DEFAULT_MARKER}" may appear once`);
|
|
146
|
+
}
|
|
147
|
+
return problems;
|
|
148
|
+
};
|
package/lib/text.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Small text and time helpers shared by connectors, rules and the store. */
|
|
2
|
+
|
|
3
|
+
export const DAY = 86400000;
|
|
4
|
+
|
|
5
|
+
export const toIsoUtc = (value) => {
|
|
6
|
+
const time = new Date(value || '').getTime();
|
|
7
|
+
return Number.isFinite(time) ? new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z') : '';
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const nowIso = () => toIsoUtc(Date.now());
|
|
11
|
+
|
|
12
|
+
export const daysAgoIso = (days) => toIsoUtc(Date.now() - days * DAY);
|
|
13
|
+
|
|
14
|
+
export const oneline = (text, limit = 140) => {
|
|
15
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim();
|
|
16
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const escapeRegex = (text) => String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
|
|
21
|
+
export const wordsRegex = (words, flags = 'i') => new RegExp(words.length ? words.map(escapeRegex).join('|') : '(?!)', flags);
|
|
22
|
+
|
|
23
|
+
export const makeKeyFinder = (pattern) => {
|
|
24
|
+
const regex = new RegExp(pattern);
|
|
25
|
+
return (text) => {
|
|
26
|
+
const match = regex.exec(text || '');
|
|
27
|
+
return match ? match[0] : '';
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const newestFirst = (list) => list.slice().sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
32
|
+
|
|
33
|
+
export const unionCapped = (list, additions, cap = 5000) => [...new Set([...list, ...additions])].slice(-cap);
|
|
34
|
+
|
|
35
|
+
export const mapLimit = async (items, limit, worker) => {
|
|
36
|
+
const results = new Array(items.length);
|
|
37
|
+
let next = 0;
|
|
38
|
+
const lane = async () => {
|
|
39
|
+
while (next < items.length) {
|
|
40
|
+
const index = next++;
|
|
41
|
+
results[index] = await worker(items[index], index);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
45
|
+
return results;
|
|
46
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The one token cache of the connectors: a token read once and kept for ten minutes. */
|
|
2
|
+
|
|
3
|
+
const TOKEN_TTL = 10 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
/** The token `read` resolves to, kept in `cache.token` for ten minutes; an empty token is read again next time. */
|
|
6
|
+
export const cachedToken = async (cache, read) => {
|
|
7
|
+
if (!cache.token || Date.now() - cache.tokenAt > TOKEN_TTL) {
|
|
8
|
+
cache.token = await read();
|
|
9
|
+
cache.tokenAt = Date.now();
|
|
10
|
+
}
|
|
11
|
+
return cache.token;
|
|
12
|
+
};
|
package/lib/update.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Self-update of an installed package: a dist-tags check every ten minutes, one desktop notification per new version, and the one-click install and restart behind POST /api/update. ADR 0007, RMA-231. */
|
|
2
|
+
import { execFile, spawn as spawnChild } from 'node:child_process';
|
|
3
|
+
import { openSync, readFileSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ApiError } from './errors.mjs';
|
|
6
|
+
import { readJson, writeJson } from './store.mjs';
|
|
7
|
+
|
|
8
|
+
export const PACKAGE = 'whose-turn';
|
|
9
|
+
export const AGENT_LABEL = 'local.whose-turn';
|
|
10
|
+
export const UPDATE_CHECK_MS = 10 * 60 * 1000;
|
|
11
|
+
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
12
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
13
|
+
|
|
14
|
+
/** Where the BladeForge plugin installs the package: `<dataDir>/app/node_modules/whose-turn`. */
|
|
15
|
+
export const installedRoot = (dataDir) => join(dataDir, 'app', 'node_modules', PACKAGE);
|
|
16
|
+
|
|
17
|
+
/** True when the server runs from the plugin's install, so an update has somewhere to land; a checkout is never offered one. */
|
|
18
|
+
export const runsInstalled = (appDir, dataDir, real = realpathSync) => {
|
|
19
|
+
try {
|
|
20
|
+
return real(appDir) === real(installedRoot(dataDir));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The dist-tag every install follows: `latest`, which the release workflow moves on every publish, prereleases included (RMA-230). */
|
|
27
|
+
export const CHANNEL = 'latest';
|
|
28
|
+
|
|
29
|
+
/** The launch agent this process runs under, as the exit code that brings it back, or null when it runs on its own: launchd names its job in XPC_SERVICE_NAME and restarts on any exit (KeepAlive); the systemd unit shows in the cgroup and restarts only on a non-zero exit (Restart=on-failure). */
|
|
30
|
+
export const probeAgent = ({ platform = process.platform, env = process.env, cgroup = () => readFileSync('/proc/self/cgroup', 'utf8') } = {}) => {
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
return env.XPC_SERVICE_NAME === AGENT_LABEL ? { exitCode: 0 } : null;
|
|
33
|
+
}
|
|
34
|
+
if (platform === 'linux') {
|
|
35
|
+
try {
|
|
36
|
+
return cgroup().includes(`/${PACKAGE}.service`) ? { exitCode: 75 } : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Runs npm and resolves with '' when it succeeded, else with the last lines it printed; on Windows npm is a .cmd, which needs a shell, so every argument is quoted. */
|
|
45
|
+
export const runNpm = (npm, args, platform = process.platform) => new Promise((resolve) => {
|
|
46
|
+
const shell = platform === 'win32';
|
|
47
|
+
execFile(npm, shell ? args.map((arg) => `"${arg}"`) : args, { timeout: INSTALL_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, shell }, (error, stdout, stderr) => {
|
|
48
|
+
if (!error) {
|
|
49
|
+
return resolve('');
|
|
50
|
+
}
|
|
51
|
+
const lines = String(stderr || stdout || error.message).split('\n').map((line) => line.trim()).filter(Boolean);
|
|
52
|
+
resolve(lines.slice(-3).join(' ').slice(0, 400) || 'npm failed');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** The restart after an install: under a launch agent exit and let it bring the new copy back; without one close the port, start the new copy detached and exit. */
|
|
57
|
+
export const restartServer = ({ server, root, dataDir, agent = () => probeAgent(), spawn = spawnChild, exit = process.exit }) => {
|
|
58
|
+
const found = agent();
|
|
59
|
+
if (found) {
|
|
60
|
+
return exit(found.exitCode);
|
|
61
|
+
}
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
server.close(resolve);
|
|
64
|
+
server.closeAllConnections();
|
|
65
|
+
}).then(() => {
|
|
66
|
+
const log = openSync(join(dataDir, 'server.log'), 'a');
|
|
67
|
+
spawn(process.execPath, [join(root, 'server.mjs')], { cwd: root, env: process.env, detached: true, stdio: ['ignore', log, log] }).unref();
|
|
68
|
+
exit(0);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The updater the API reads and writes; `installed` false (a checkout) keeps it idle and offers nothing. */
|
|
73
|
+
export const createUpdater = ({ http, dataDir, version, installed, notify, registry = 'https://registry.npmjs.org', npm = 'npm', run = runNpm, restart }) => {
|
|
74
|
+
const listeners = new Set();
|
|
75
|
+
const memoFile = join(dataDir, 'update.json');
|
|
76
|
+
let latest = '';
|
|
77
|
+
let state = 'idle';
|
|
78
|
+
let reason = '';
|
|
79
|
+
|
|
80
|
+
const status = () => ({ current: version, latest, available: installed && Boolean(latest) && latest !== version, state, ...(state === 'failed' ? { reason } : {}) });
|
|
81
|
+
const announce = () => listeners.forEach((listener) => listener({ update: status() }));
|
|
82
|
+
const move = (next, why = '') => {
|
|
83
|
+
state = next;
|
|
84
|
+
reason = why;
|
|
85
|
+
announce();
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const check = async () => {
|
|
89
|
+
if (!installed) {
|
|
90
|
+
return status();
|
|
91
|
+
}
|
|
92
|
+
const found = await http.fetchJson(`${registry}/-/package/${PACKAGE}/dist-tags`).then((tags) => tags && tags[CHANNEL], () => '');
|
|
93
|
+
if (typeof found === 'string' && VERSION.test(found) && found !== latest) {
|
|
94
|
+
latest = found;
|
|
95
|
+
announce();
|
|
96
|
+
if (found !== version && readJson(memoFile, {}).notified !== found) {
|
|
97
|
+
writeJson(memoFile, { notified: found });
|
|
98
|
+
notify(`Whose Turn ${found} is out`, 'Update from the window or your next session.');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return status();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const install = async () => {
|
|
105
|
+
const failure = await run(npm, ['install', '--prefix', join(dataDir, 'app'), '--no-audit', '--no-fund', '--no-save', `${PACKAGE}@${latest}`]);
|
|
106
|
+
if (failure) {
|
|
107
|
+
return move('failed', failure);
|
|
108
|
+
}
|
|
109
|
+
move('restarting');
|
|
110
|
+
await restart();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const apply = () => {
|
|
114
|
+
if (state === 'installing' || state === 'restarting') {
|
|
115
|
+
throw new ApiError('UPDATE_IN_PROGRESS', { state });
|
|
116
|
+
}
|
|
117
|
+
if (!status().available) {
|
|
118
|
+
throw new ApiError('UPDATE_UNAVAILABLE', { current: version, latest });
|
|
119
|
+
}
|
|
120
|
+
move('installing');
|
|
121
|
+
install().catch((error) => move('failed', String(error?.message || error)));
|
|
122
|
+
return status();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const start = () => {
|
|
126
|
+
check();
|
|
127
|
+
setInterval(check, UPDATE_CHECK_MS).unref();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return { status, check, apply, start, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); } };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** An updater for a server with nothing to update, the API's default. */
|
|
134
|
+
export const noUpdates = () => createUpdater({ http: null, dataDir: '', version: '', installed: false, notify: () => {}, restart: async () => {} });
|
package/lib/window.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The app window's remembered size in <data>/window.json, and the launcher behind the window's "Open as app" button. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ApiError } from './errors.mjs';
|
|
5
|
+
import { readJson, writeJson } from './store.mjs';
|
|
6
|
+
|
|
7
|
+
const MODES = ['app', 'browser'];
|
|
8
|
+
const inRange = (value) => Number.isInteger(value) && value >= 300 && value <= 10000;
|
|
9
|
+
|
|
10
|
+
export const readSize = (dataDir) => {
|
|
11
|
+
const { width, height } = readJson(join(dataDir, 'window.json'), {});
|
|
12
|
+
return inRange(width) && inRange(height) ? { width, height } : {};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const saveSize = (dataDir, { width, height }) => {
|
|
16
|
+
if (!inRange(width) || !inRange(height)) {
|
|
17
|
+
throw new ApiError('INVALID_ARGUMENT', { detail: 'width and height are whole pixels between 300 and 10000' });
|
|
18
|
+
}
|
|
19
|
+
writeJson(join(dataDir, 'window.json'), { width, height });
|
|
20
|
+
return { width, height };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const checkMode = (mode) => {
|
|
24
|
+
if (!MODES.includes(mode)) {
|
|
25
|
+
throw new ApiError('INVALID_ARGUMENT', { detail: `mode is one of ${MODES.join(', ')}` });
|
|
26
|
+
}
|
|
27
|
+
return mode;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const spawnOpener = (appDir) => ({ mode, port }) => {
|
|
31
|
+
const args = mode === 'browser' ? ['--browser'] : [];
|
|
32
|
+
spawn(join(appDir, 'open.sh'), args, { env: { ...process.env, WHOSE_TURN_PORT: String(port) }, detached: true, stdio: 'ignore' }).unref();
|
|
33
|
+
};
|