@alisio/server 0.9.0 → 0.10.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/dist/index.d.ts +25 -0
- package/dist/index.js +40 -2
- package/dist/plugins/catalog-schema.d.ts +37 -0
- package/dist/plugins/catalog-schema.js +141 -0
- package/dist/plugins/catalog-service.d.ts +89 -0
- package/dist/plugins/catalog-service.js +306 -0
- package/dist/plugins/jobs.d.ts +41 -0
- package/dist/plugins/jobs.js +94 -0
- package/dist/plugins/sdk-version.d.ts +6 -0
- package/dist/plugins/sdk-version.js +28 -0
- package/dist/plugins/staging.d.ts +13 -0
- package/dist/plugins/staging.js +48 -0
- package/dist/routes/plugin-catalog.d.ts +103 -0
- package/dist/routes/plugin-catalog.js +436 -0
- package/dist/routes/server-restart.d.ts +7 -0
- package/dist/routes/server-restart.js +24 -0
- package/dist/web/assets/{AgentPicker-Dnt56QuW.js → AgentPicker-B2dNJHaW.js} +1 -1
- package/dist/web/assets/{AgentsModal-w9FC_Qnw.js → AgentsModal-BWrVQpCo.js} +1 -1
- package/dist/web/assets/{ArtifactPanel-x0WO1Jrn.js → ArtifactPanel-ZkBV87x7.js} +3 -3
- package/dist/web/assets/{BtwPanel-DCnyqvqF.js → BtwPanel-BtIHip-u.js} +1 -1
- package/dist/web/assets/{ChangelogDialog-B7_Imxij.js → ChangelogDialog-NBwCG0Bo.js} +1 -1
- package/dist/web/assets/{ContextTab-MaeId0Vq.js → ContextTab-Bi8MZwMV.js} +1 -1
- package/dist/web/assets/{CopyButton-C701fVen.js → CopyButton-Bh3Dsk-o.js} +1 -1
- package/dist/web/assets/{GoalBar-2u4r5hjt.js → GoalBar-DVwQsVtS.js} +1 -1
- package/dist/web/assets/{MemoryTab-MDHnMK7j.js → MemoryTab-CjZTbnFG.js} +1 -1
- package/dist/web/assets/{PermissionsMenu-Cn5x9qqW.js → PermissionsMenu-B03X019W.js} +1 -1
- package/dist/web/assets/{PlanReviewPanel-6Ke6aA-x.js → PlanReviewPanel-CM1uxJVs.js} +1 -1
- package/dist/web/assets/{PlanViewer-CNB31hZF.js → PlanViewer-DxAqn6IP.js} +1 -1
- package/dist/web/assets/{ProofCard-CGzCT48Z.js → ProofCard-B2nxuxaT.js} +1 -1
- package/dist/web/assets/{ScratchTab-DcTzefmZ.js → ScratchTab-BLkjPbJC.js} +1 -1
- package/dist/web/assets/SettingsModal-BJj7ELxX.js +3 -0
- package/dist/web/assets/{SpreadsheetPanel-SfCpgyiY.js → SpreadsheetPanel-BJLNla1t.js} +1 -1
- package/dist/web/assets/{TasksTab-B50lA0Fx.js → TasksTab-DhKGW5By.js} +1 -1
- package/dist/web/assets/{TextPreview-B63NbGfK.js → TextPreview-CcGkaf6g.js} +1 -1
- package/dist/web/assets/{TrajectoryTab-D2E8DaIt.js → TrajectoryTab-U37sw2D0.js} +1 -1
- package/dist/web/assets/{agents.module-CrFdY3Vj.js → agents.module-C5jXUIc1.js} +1 -1
- package/dist/web/assets/{app-BH2h7887.js → app-CGSX2FCK.js} +3 -3
- package/dist/web/assets/{es-sJS5auEJ.js → es-DP4ejr3u.js} +1 -1
- package/dist/web/assets/{goal-WebPe6na.js → goal-Cib1wj1f.js} +1 -1
- package/dist/web/assets/{i18n-CNPXQ8kr.js → i18n-DqOZSMVh.js} +2 -2
- package/dist/web/assets/{icons-D2ozMmbS.js → icons-BZSZMbqh.js} +1 -1
- package/dist/web/assets/{index-BF8cv-HX.js → index-CSQo6Bhi.js} +6 -6
- package/dist/web/assets/{jsxRuntime-BwIKcin2.js → jsxRuntime-BWhXelbu.js} +1 -1
- package/dist/web/assets/{panel-DF57sVn-.js → panel-IZ40pPYp.js} +1 -1
- package/dist/web/assets/settings-BAW93knU.css +1 -0
- package/dist/web/assets/settings.module-ErkNzIk4.js +1 -0
- package/dist/web/assets/{shared-DM5LTOJl.js → shared-C2fqplXu.js} +1 -1
- package/dist/web/assets/{strings-CNoJKZqb.js → strings-BUuRkgRy.js} +1 -1
- package/dist/web/assets/{tasks-D13Pmq9A.js → tasks-Bw0p2a6l.js} +1 -1
- package/dist/web/assets/{view-UpLwcG_p.js → view-B95Eg9o2.js} +1 -1
- package/dist/web/assets/view-BeVPW5cE.js +1 -0
- package/dist/web/assets/{view-B-6zUlTX.js → view-BkR3NRaK.js} +1 -1
- package/dist/web/assets/{view-BeUe5oNy.js → view-C99qRBgc.js} +1 -1
- package/dist/web/assets/{view-CfyBjrdk.js → view-CI66xheW.js} +1 -1
- package/dist/web/assets/{view-BM0IB6Wb.js → view-CSAgn7-g.js} +1 -1
- package/dist/web/assets/{view-1mNrG8x0.js → view-DJAfpDXs.js} +1 -1
- package/dist/web/assets/{view-JquKQHkw.js → view-DrHx2F_3.js} +1 -1
- package/dist/web/assets/{view-CTmPqVpP.js → view-Du7qfIhE.js} +1 -1
- package/dist/web/index.html +7 -7
- package/package.json +4 -4
- package/dist/web/assets/SettingsModal-BEuZITsr.js +0 -1
- package/dist/web/assets/settings-DdWABXsT.css +0 -1
- package/dist/web/assets/settings.module-BClxMWCR.js +0 -1
- package/dist/web/assets/view-vG-2EBMR.js +0 -1
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin catalog client: one remote JSON document, cached on disk and revalidated only when it
|
|
3
|
+
* is stale (24 h by default) or the user asks. A revalidation is a conditional request, so an
|
|
4
|
+
* unchanged catalog costs a 304. Offline with a cache serves the cache; offline without one fails.
|
|
5
|
+
* Covers are proxied from the catalog origin through a disk cache so the browser never makes a
|
|
6
|
+
* third-party request (the web UI CSP would block it anyway).
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { stateHome } from "@alisio/core";
|
|
12
|
+
import { safeUrl, sameOrigin, sanitizeCatalog, } from "./catalog-schema.js";
|
|
13
|
+
export const DEFAULT_CATALOG_URL = "https://gustavogutierrez.github.io/alisio-plugins/catalog.json";
|
|
14
|
+
export const CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
export const CATALOG_TIMEOUT_MS = 10_000;
|
|
16
|
+
export const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
|
17
|
+
export const MAX_COVER_BYTES = 512 * 1024;
|
|
18
|
+
/**
|
|
19
|
+
* The on-disk form of a sanitized catalog: the cover source goes back under its catalog key so the
|
|
20
|
+
* cache is validated by the same `sanitizeCatalog` it was produced by.
|
|
21
|
+
*/
|
|
22
|
+
function storable(catalog) {
|
|
23
|
+
return {
|
|
24
|
+
...catalog,
|
|
25
|
+
plugins: catalog.plugins.map(({ coverSource, ...rest }) => ({
|
|
26
|
+
...rest,
|
|
27
|
+
...(coverSource ? { cover: coverSource } : {}),
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export class CatalogUnavailableError extends Error {
|
|
32
|
+
}
|
|
33
|
+
/** Image types a cover may have, recognised by their bytes (never by the origin's header). */
|
|
34
|
+
const COVER_TYPES = [
|
|
35
|
+
{ type: "image/svg+xml", ext: "svg" },
|
|
36
|
+
{ type: "image/webp", ext: "webp" },
|
|
37
|
+
{ type: "image/png", ext: "png" },
|
|
38
|
+
{ type: "image/jpeg", ext: "jpg" },
|
|
39
|
+
];
|
|
40
|
+
export function sniffCover(body) {
|
|
41
|
+
if (body.subarray(0, 4).toString("latin1") === "RIFF" &&
|
|
42
|
+
body.subarray(8, 12).toString("latin1") === "WEBP")
|
|
43
|
+
return COVER_TYPES[1];
|
|
44
|
+
if (body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
45
|
+
return COVER_TYPES[2];
|
|
46
|
+
if (body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff)
|
|
47
|
+
return COVER_TYPES[3];
|
|
48
|
+
if (/<svg[\s>]/i.test(body.toString("utf8", 0, Math.min(body.length, 4096))))
|
|
49
|
+
return COVER_TYPES[0];
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Body of `res` as a Buffer, refusing more than `max` bytes. */
|
|
53
|
+
async function readCapped(res, max) {
|
|
54
|
+
const declared = Number(res.headers.get("content-length") ?? Number.NaN);
|
|
55
|
+
if (Number.isFinite(declared) && declared > max)
|
|
56
|
+
throw new Error("The response is too large");
|
|
57
|
+
if (!res.body)
|
|
58
|
+
return Buffer.alloc(0);
|
|
59
|
+
const reader = res.body.getReader();
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const { done, value } = await reader.read();
|
|
64
|
+
if (done)
|
|
65
|
+
break;
|
|
66
|
+
size += value.byteLength;
|
|
67
|
+
if (size > max) {
|
|
68
|
+
await reader.cancel().catch(() => { });
|
|
69
|
+
throw new Error("The response is too large");
|
|
70
|
+
}
|
|
71
|
+
chunks.push(Buffer.from(value));
|
|
72
|
+
}
|
|
73
|
+
return Buffer.concat(chunks);
|
|
74
|
+
}
|
|
75
|
+
async function writeAtomic(file, data) {
|
|
76
|
+
await mkdir(join(file, ".."), { recursive: true, mode: 0o700 });
|
|
77
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(temporary, data, { mode: 0o600 });
|
|
80
|
+
await rename(temporary, file);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
88
|
+
export class CatalogService {
|
|
89
|
+
options;
|
|
90
|
+
inflight = new Map();
|
|
91
|
+
coverInflight = new Map();
|
|
92
|
+
fetcher;
|
|
93
|
+
clock;
|
|
94
|
+
constructor(options = {}) {
|
|
95
|
+
this.options = options;
|
|
96
|
+
this.fetcher = options.fetch ?? fetch;
|
|
97
|
+
this.clock = options.now ?? Date.now;
|
|
98
|
+
}
|
|
99
|
+
get url() {
|
|
100
|
+
return this.options.url ?? process.env.ALISIO_PLUGIN_CATALOG_URL ?? DEFAULT_CATALOG_URL;
|
|
101
|
+
}
|
|
102
|
+
get ttl() {
|
|
103
|
+
return this.options.ttlMs ?? CATALOG_TTL_MS;
|
|
104
|
+
}
|
|
105
|
+
get cacheFile() {
|
|
106
|
+
return join(stateHome(), "cache", "plugin-catalog.json");
|
|
107
|
+
}
|
|
108
|
+
coverFile(slug, ext) {
|
|
109
|
+
return join(stateHome(), "cache", "plugin-covers", `${slug}.${ext}`);
|
|
110
|
+
}
|
|
111
|
+
coverMetaFile(slug) {
|
|
112
|
+
return join(stateHome(), "cache", "plugin-covers", `${slug}.json`);
|
|
113
|
+
}
|
|
114
|
+
async readCache() {
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(await readFile(this.cacheFile, "utf8"));
|
|
117
|
+
if (parsed.url !== this.url || !parsed.meta || typeof parsed.meta.checkedAt !== "number")
|
|
118
|
+
return undefined;
|
|
119
|
+
// Re-sanitized on every read: the file is a cache on disk, not a trusted source.
|
|
120
|
+
const catalog = sanitizeCatalog({ schemaVersion: 1, ...parsed.catalog }, this.url);
|
|
121
|
+
return { url: parsed.url, meta: parsed.meta, catalog };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** The catalog: from cache when fresh, else revalidated (`refresh` forces the check). */
|
|
128
|
+
get(refresh = false) {
|
|
129
|
+
const running = this.inflight.get(refresh);
|
|
130
|
+
if (running)
|
|
131
|
+
return running;
|
|
132
|
+
const task = this.load(refresh).finally(() => this.inflight.delete(refresh));
|
|
133
|
+
this.inflight.set(refresh, task);
|
|
134
|
+
return task;
|
|
135
|
+
}
|
|
136
|
+
async load(refresh) {
|
|
137
|
+
const cache = await this.readCache();
|
|
138
|
+
const now = this.clock();
|
|
139
|
+
if (cache && !refresh && now - cache.meta.checkedAt < this.ttl)
|
|
140
|
+
return this.result(cache, { fromCache: true, offline: false });
|
|
141
|
+
try {
|
|
142
|
+
return await this.revalidate(cache);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (cache)
|
|
146
|
+
return this.result(cache, { fromCache: true, offline: true, error: message(error) });
|
|
147
|
+
throw new CatalogUnavailableError(message(error));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
result(cache, state) {
|
|
151
|
+
return {
|
|
152
|
+
catalog: cache.catalog,
|
|
153
|
+
meta: {
|
|
154
|
+
checkedAt: cache.meta.checkedAt,
|
|
155
|
+
fetchedAt: cache.meta.fetchedAt,
|
|
156
|
+
fromCache: state.fromCache,
|
|
157
|
+
stale: this.clock() - cache.meta.checkedAt >= this.ttl,
|
|
158
|
+
offline: state.offline,
|
|
159
|
+
...(state.error ? { error: state.error } : {}),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async revalidate(cache) {
|
|
164
|
+
const url = this.url;
|
|
165
|
+
const target = safeUrl(url, 2048, true);
|
|
166
|
+
if (!target)
|
|
167
|
+
throw new Error("The plugin catalog URL must be https");
|
|
168
|
+
const headers = { Accept: "application/json" };
|
|
169
|
+
if (cache?.meta.etag)
|
|
170
|
+
headers["If-None-Match"] = cache.meta.etag;
|
|
171
|
+
if (cache?.meta.lastModified)
|
|
172
|
+
headers["If-Modified-Since"] = cache.meta.lastModified;
|
|
173
|
+
const res = await this.fetcher(url, {
|
|
174
|
+
headers,
|
|
175
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? CATALOG_TIMEOUT_MS),
|
|
176
|
+
});
|
|
177
|
+
this.assertSameOrigin(res, target);
|
|
178
|
+
const now = this.clock();
|
|
179
|
+
if (res.status === 304 && cache) {
|
|
180
|
+
await res.body?.cancel().catch(() => { });
|
|
181
|
+
const next = { ...cache, meta: { ...cache.meta, checkedAt: now } };
|
|
182
|
+
await writeAtomic(this.cacheFile, JSON.stringify({ ...next, catalog: storable(next.catalog) })).catch(() => { });
|
|
183
|
+
return this.result(next, { fromCache: true, offline: false });
|
|
184
|
+
}
|
|
185
|
+
if (!res.ok) {
|
|
186
|
+
await res.body?.cancel().catch(() => { });
|
|
187
|
+
throw new Error(`The plugin catalog server answered HTTP ${res.status}`);
|
|
188
|
+
}
|
|
189
|
+
const raw = await readCapped(res, MAX_CATALOG_BYTES);
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw new Error("The plugin catalog is not valid JSON");
|
|
196
|
+
}
|
|
197
|
+
const catalog = sanitizeCatalog(parsed, url);
|
|
198
|
+
const etag = res.headers.get("etag") ?? undefined;
|
|
199
|
+
const lastModified = res.headers.get("last-modified") ?? undefined;
|
|
200
|
+
const next = {
|
|
201
|
+
url,
|
|
202
|
+
meta: {
|
|
203
|
+
...(etag ? { etag } : {}),
|
|
204
|
+
...(lastModified ? { lastModified } : {}),
|
|
205
|
+
fetchedAt: now,
|
|
206
|
+
checkedAt: now,
|
|
207
|
+
},
|
|
208
|
+
catalog,
|
|
209
|
+
};
|
|
210
|
+
await writeAtomic(this.cacheFile, JSON.stringify({ ...next, catalog: storable(catalog) })).catch(() => { });
|
|
211
|
+
return this.result(next, { fromCache: false, offline: false });
|
|
212
|
+
}
|
|
213
|
+
assertSameOrigin(res, expected) {
|
|
214
|
+
if (!res.url)
|
|
215
|
+
return;
|
|
216
|
+
let final;
|
|
217
|
+
try {
|
|
218
|
+
final = new URL(res.url);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!sameOrigin(final, expected))
|
|
224
|
+
throw new Error("The plugin catalog redirected to another origin");
|
|
225
|
+
}
|
|
226
|
+
/** A plugin of the current catalog by slug (cache, revalidated only when stale). */
|
|
227
|
+
async plugin(slug) {
|
|
228
|
+
const result = await this.get(false);
|
|
229
|
+
const plugin = result.catalog.plugins.find((entry) => entry.slug === slug);
|
|
230
|
+
return plugin ? { plugin, result } : undefined;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The cover SVG of a catalog plugin. It is fetched from the sanitized catalog URL only and kept
|
|
234
|
+
* on disk; it is revalidated (conditionally) only when the catalog content changed since it was
|
|
235
|
+
* last checked, and an unreachable origin falls back to the copy on disk.
|
|
236
|
+
*/
|
|
237
|
+
cover(plugin, catalogFetchedAt) {
|
|
238
|
+
const key = plugin.slug;
|
|
239
|
+
const running = this.coverInflight.get(key);
|
|
240
|
+
if (running)
|
|
241
|
+
return running;
|
|
242
|
+
const task = this.loadCover(plugin, catalogFetchedAt).finally(() => this.coverInflight.delete(key));
|
|
243
|
+
this.coverInflight.set(key, task);
|
|
244
|
+
return task;
|
|
245
|
+
}
|
|
246
|
+
async loadCover(plugin, catalogFetchedAt) {
|
|
247
|
+
const metaFile = this.coverMetaFile(plugin.slug);
|
|
248
|
+
let meta = {};
|
|
249
|
+
let body;
|
|
250
|
+
try {
|
|
251
|
+
meta = JSON.parse(await readFile(metaFile, "utf8"));
|
|
252
|
+
if (meta.ext && COVER_TYPES.some((entry) => entry.ext === meta.ext))
|
|
253
|
+
body = await readFile(this.coverFile(plugin.slug, meta.ext));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
/* no usable copy yet */
|
|
257
|
+
}
|
|
258
|
+
const source = plugin.coverSource;
|
|
259
|
+
if (!source)
|
|
260
|
+
return undefined;
|
|
261
|
+
const usable = body !== undefined && meta.source === source && !!meta.contentType;
|
|
262
|
+
const served = (data, contentType, upstream) => ({
|
|
263
|
+
body: data,
|
|
264
|
+
etag: upstream ?? this.etagOf(data),
|
|
265
|
+
contentType,
|
|
266
|
+
});
|
|
267
|
+
if (usable && body && meta.contentType && (meta.checkedAt ?? 0) >= catalogFetchedAt)
|
|
268
|
+
return served(body, meta.contentType, meta.upstream);
|
|
269
|
+
try {
|
|
270
|
+
const headers = {};
|
|
271
|
+
if (usable && meta.upstream)
|
|
272
|
+
headers["If-None-Match"] = meta.upstream;
|
|
273
|
+
const res = await this.fetcher(source, {
|
|
274
|
+
headers,
|
|
275
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? CATALOG_TIMEOUT_MS),
|
|
276
|
+
});
|
|
277
|
+
this.assertSameOrigin(res, new URL(this.url));
|
|
278
|
+
const now = this.clock();
|
|
279
|
+
if (res.status === 304 && usable && body && meta.contentType) {
|
|
280
|
+
await res.body?.cancel().catch(() => { });
|
|
281
|
+
await writeAtomic(metaFile, JSON.stringify({ ...meta, checkedAt: now })).catch(() => { });
|
|
282
|
+
return served(body, meta.contentType, meta.upstream);
|
|
283
|
+
}
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
await res.body?.cancel().catch(() => { });
|
|
286
|
+
throw new Error(`HTTP ${res.status}`);
|
|
287
|
+
}
|
|
288
|
+
const fresh = await readCapped(res, MAX_COVER_BYTES);
|
|
289
|
+
const kind = sniffCover(fresh);
|
|
290
|
+
if (!kind)
|
|
291
|
+
throw new Error("The cover is not an SVG, WebP, PNG or JPEG image");
|
|
292
|
+
const upstream = res.headers.get("etag") ?? undefined;
|
|
293
|
+
await writeAtomic(this.coverFile(plugin.slug, kind.ext), fresh).catch(() => { });
|
|
294
|
+
await writeAtomic(metaFile, JSON.stringify({ source, upstream, checkedAt: now, contentType: kind.type, ext: kind.ext })).catch(() => { });
|
|
295
|
+
return served(fresh, kind.type, upstream);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return usable && body && meta.contentType
|
|
299
|
+
? served(body, meta.contentType, meta.upstream)
|
|
300
|
+
: undefined;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
etagOf(body) {
|
|
304
|
+
return `"sha1-${createHash("sha1").update(body).digest("hex")}"`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type PhaseId = "uploading" | "validating" | "copying" | "installing" | "registering" | "reloading" | "cleanup";
|
|
2
|
+
export declare const INSTALL_PHASES: readonly PhaseId[];
|
|
3
|
+
export type PhaseStatus = "pending" | "running" | "done" | "failed";
|
|
4
|
+
/** `GET /api/plugins/jobs/:jobId`. */
|
|
5
|
+
export interface JobView {
|
|
6
|
+
phase?: PhaseId;
|
|
7
|
+
phases: Array<{
|
|
8
|
+
id: PhaseId;
|
|
9
|
+
status: PhaseStatus;
|
|
10
|
+
}>;
|
|
11
|
+
done: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
result?: unknown;
|
|
14
|
+
logTail?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const JOB_TTL_MS: number;
|
|
17
|
+
export declare class InstallJob {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
private status;
|
|
20
|
+
private current;
|
|
21
|
+
private finished;
|
|
22
|
+
private failure;
|
|
23
|
+
private outcome;
|
|
24
|
+
touched: number;
|
|
25
|
+
constructor(id: string, now: number);
|
|
26
|
+
/** Marks the running phase done and `phase` running. */
|
|
27
|
+
start(phase: PhaseId, now?: number): void;
|
|
28
|
+
succeed(result: unknown, now?: number): void;
|
|
29
|
+
fail(error: string, logTail?: string, now?: number): void;
|
|
30
|
+
get done(): boolean;
|
|
31
|
+
view(): JobView;
|
|
32
|
+
}
|
|
33
|
+
export declare class InstallJobs {
|
|
34
|
+
private ttlMs;
|
|
35
|
+
private now;
|
|
36
|
+
private jobs;
|
|
37
|
+
constructor(ttlMs?: number, now?: () => number);
|
|
38
|
+
create(): InstallJob;
|
|
39
|
+
get(id: string): InstallJob | undefined;
|
|
40
|
+
private sweep;
|
|
41
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory install jobs: a local-file install runs in the background and the browser polls its
|
|
3
|
+
* phase list. Jobs expire 10 minutes after they finish (or start, if they never do).
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
export const INSTALL_PHASES = [
|
|
7
|
+
"uploading",
|
|
8
|
+
"validating",
|
|
9
|
+
"copying",
|
|
10
|
+
"installing",
|
|
11
|
+
"registering",
|
|
12
|
+
"reloading",
|
|
13
|
+
"cleanup",
|
|
14
|
+
];
|
|
15
|
+
export const JOB_TTL_MS = 10 * 60 * 1000;
|
|
16
|
+
export class InstallJob {
|
|
17
|
+
id;
|
|
18
|
+
status = new Map(INSTALL_PHASES.map((id) => [id, "pending"]));
|
|
19
|
+
current;
|
|
20
|
+
finished = false;
|
|
21
|
+
failure;
|
|
22
|
+
outcome;
|
|
23
|
+
touched;
|
|
24
|
+
constructor(id, now) {
|
|
25
|
+
this.id = id;
|
|
26
|
+
this.touched = now;
|
|
27
|
+
}
|
|
28
|
+
/** Marks the running phase done and `phase` running. */
|
|
29
|
+
start(phase, now = Date.now()) {
|
|
30
|
+
if (this.current && this.status.get(this.current) === "running")
|
|
31
|
+
this.status.set(this.current, "done");
|
|
32
|
+
// Phases skipped over (the core does not report "uploading") count as done.
|
|
33
|
+
for (const id of INSTALL_PHASES) {
|
|
34
|
+
if (id === phase)
|
|
35
|
+
break;
|
|
36
|
+
if (this.status.get(id) === "pending")
|
|
37
|
+
this.status.set(id, "done");
|
|
38
|
+
}
|
|
39
|
+
this.status.set(phase, "running");
|
|
40
|
+
this.current = phase;
|
|
41
|
+
this.touched = now;
|
|
42
|
+
}
|
|
43
|
+
succeed(result, now = Date.now()) {
|
|
44
|
+
for (const id of INSTALL_PHASES)
|
|
45
|
+
this.status.set(id, "done");
|
|
46
|
+
this.finished = true;
|
|
47
|
+
this.outcome = result;
|
|
48
|
+
this.touched = now;
|
|
49
|
+
}
|
|
50
|
+
fail(error, logTail, now = Date.now()) {
|
|
51
|
+
if (this.current)
|
|
52
|
+
this.status.set(this.current, "failed");
|
|
53
|
+
this.finished = true;
|
|
54
|
+
this.failure = { error, ...(logTail ? { logTail } : {}) };
|
|
55
|
+
this.touched = now;
|
|
56
|
+
}
|
|
57
|
+
get done() {
|
|
58
|
+
return this.finished;
|
|
59
|
+
}
|
|
60
|
+
view() {
|
|
61
|
+
return {
|
|
62
|
+
...(this.current ? { phase: this.current } : {}),
|
|
63
|
+
phases: INSTALL_PHASES.map((id) => ({ id, status: this.status.get(id) ?? "pending" })),
|
|
64
|
+
done: this.finished,
|
|
65
|
+
...(this.failure ? this.failure : {}),
|
|
66
|
+
...(this.finished && !this.failure ? { result: this.outcome } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export class InstallJobs {
|
|
71
|
+
ttlMs;
|
|
72
|
+
now;
|
|
73
|
+
jobs = new Map();
|
|
74
|
+
constructor(ttlMs = JOB_TTL_MS, now = Date.now) {
|
|
75
|
+
this.ttlMs = ttlMs;
|
|
76
|
+
this.now = now;
|
|
77
|
+
}
|
|
78
|
+
create() {
|
|
79
|
+
this.sweep();
|
|
80
|
+
const job = new InstallJob(randomUUID(), this.now());
|
|
81
|
+
this.jobs.set(job.id, job);
|
|
82
|
+
return job;
|
|
83
|
+
}
|
|
84
|
+
get(id) {
|
|
85
|
+
this.sweep();
|
|
86
|
+
return this.jobs.get(id);
|
|
87
|
+
}
|
|
88
|
+
sweep() {
|
|
89
|
+
const cutoff = this.now() - this.ttlMs;
|
|
90
|
+
for (const [id, job] of this.jobs)
|
|
91
|
+
if (job.done && job.touched < cutoff)
|
|
92
|
+
this.jobs.delete(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version of the installed `@alisio/sdk`, read from the nearest `package.json` of the package the
|
|
3
|
+
* server itself resolves. `undefined` when it cannot be found (a standalone binary, say): the
|
|
4
|
+
* catalog then reports every plugin as compatible instead of guessing.
|
|
5
|
+
*/
|
|
6
|
+
export declare function installedSdkVersion(): string | undefined;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Version of the installed `@alisio/sdk`, read from the nearest `package.json` of the package the
|
|
6
|
+
* server itself resolves. `undefined` when it cannot be found (a standalone binary, say): the
|
|
7
|
+
* catalog then reports every plugin as compatible instead of guessing.
|
|
8
|
+
*/
|
|
9
|
+
export function installedSdkVersion() {
|
|
10
|
+
try {
|
|
11
|
+
let dir = dirname(fileURLToPath(import.meta.resolve("@alisio/sdk")));
|
|
12
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
13
|
+
try {
|
|
14
|
+
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
15
|
+
if (manifest.name === "@alisio/sdk")
|
|
16
|
+
return manifest.version;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* keep walking up */
|
|
20
|
+
}
|
|
21
|
+
dir = dirname(dir);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* not resolvable here */
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const STAGING_MAX_AGE_MS: number;
|
|
2
|
+
export declare const stagingRoot: () => string;
|
|
3
|
+
export declare const isStagingId: (value: string) => boolean;
|
|
4
|
+
export declare const stagingDir: (id: string) => string;
|
|
5
|
+
export declare const stagingFile: (id: string) => string;
|
|
6
|
+
export declare function createStaging(): Promise<{
|
|
7
|
+
id: string;
|
|
8
|
+
dir: string;
|
|
9
|
+
file: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function removeStaging(id: string): Promise<void>;
|
|
12
|
+
/** Removes staging directories not touched for `maxAgeMs`; returns how many it removed. */
|
|
13
|
+
export declare function sweepStaging(now?: number, maxAgeMs?: number): Promise<number>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging area of uploaded plugin tarballs: `<stateHome>/tmp/plugin-uploads/<id>/plugin.tgz`.
|
|
3
|
+
* Everything here is temporary: it is deleted after install, failure or cancel, and directories
|
|
4
|
+
* older than an hour are swept at server start and before each upload.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { stateHome } from "@alisio/core";
|
|
10
|
+
export const STAGING_MAX_AGE_MS = 60 * 60 * 1000;
|
|
11
|
+
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
12
|
+
export const stagingRoot = () => join(stateHome(), "tmp", "plugin-uploads");
|
|
13
|
+
export const isStagingId = (value) => ID.test(value);
|
|
14
|
+
export const stagingDir = (id) => join(stagingRoot(), id);
|
|
15
|
+
export const stagingFile = (id) => join(stagingDir(id), "plugin.tgz");
|
|
16
|
+
export async function createStaging() {
|
|
17
|
+
const id = randomUUID();
|
|
18
|
+
await mkdir(stagingDir(id), { recursive: true, mode: 0o700 });
|
|
19
|
+
return { id, dir: stagingDir(id), file: stagingFile(id) };
|
|
20
|
+
}
|
|
21
|
+
export async function removeStaging(id) {
|
|
22
|
+
if (isStagingId(id))
|
|
23
|
+
await rm(stagingDir(id), { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
/** Removes staging directories not touched for `maxAgeMs`; returns how many it removed. */
|
|
26
|
+
export async function sweepStaging(now = Date.now(), maxAgeMs = STAGING_MAX_AGE_MS) {
|
|
27
|
+
let removed = 0;
|
|
28
|
+
let names;
|
|
29
|
+
try {
|
|
30
|
+
names = await readdir(stagingRoot());
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
for (const name of names) {
|
|
36
|
+
const dir = join(stagingRoot(), name);
|
|
37
|
+
try {
|
|
38
|
+
if (now - (await stat(dir)).mtimeMs >= maxAgeMs) {
|
|
39
|
+
await rm(dir, { recursive: true, force: true });
|
|
40
|
+
removed++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* gone already */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return removed;
|
|
48
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type InstallRunner } from "@alisio/core";
|
|
2
|
+
import type { Router } from "../http/router.ts";
|
|
3
|
+
import type { CatalogPlugin } from "../plugins/catalog-schema.ts";
|
|
4
|
+
import { type CatalogResult, type CatalogService } from "../plugins/catalog-service.ts";
|
|
5
|
+
import { type JobView } from "../plugins/jobs.ts";
|
|
6
|
+
import type { ManagementContext, WorkspaceRecycler } from "./management.ts";
|
|
7
|
+
export interface PluginCatalogRouteOptions {
|
|
8
|
+
service: CatalogService;
|
|
9
|
+
/** Version of the installed `@alisio/sdk` (compatibility checks); unknown = all compatible. */
|
|
10
|
+
sdkVersion?: string;
|
|
11
|
+
/** Injected npm for tests; defaults to the real (or bundled) npm. */
|
|
12
|
+
installRunner?: InstallRunner;
|
|
13
|
+
/** Upload cap of a local plugin tarball (default 50 MB); tests lower it. */
|
|
14
|
+
maxUploadBytes?: number;
|
|
15
|
+
/** How long a finished install job stays readable (default 10 minutes). */
|
|
16
|
+
jobTtlMs?: number;
|
|
17
|
+
}
|
|
18
|
+
/** `POST /api/plugins/local/inspect`: what the uploaded tarball is, before anything is installed. */
|
|
19
|
+
export interface LocalInspectResponse {
|
|
20
|
+
/** Present only when the tarball can be installed; pass it to `local/install`. */
|
|
21
|
+
stagingId?: string;
|
|
22
|
+
package?: string;
|
|
23
|
+
version?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
author?: string;
|
|
26
|
+
license?: string;
|
|
27
|
+
sdkRange?: string;
|
|
28
|
+
/** A warning, not a block: private plugins may lag behind the installed SDK. */
|
|
29
|
+
compatible: boolean;
|
|
30
|
+
isAlisioPlugin: boolean;
|
|
31
|
+
reasons: string[];
|
|
32
|
+
lifecycleScripts: string[];
|
|
33
|
+
installed?: {
|
|
34
|
+
version: string;
|
|
35
|
+
relation: "update" | "same" | "downgrade";
|
|
36
|
+
};
|
|
37
|
+
size: number;
|
|
38
|
+
}
|
|
39
|
+
export type LocalInstallJobResponse = {
|
|
40
|
+
jobId: string;
|
|
41
|
+
};
|
|
42
|
+
export type { JobView };
|
|
43
|
+
/** A catalog plugin as the browser sees it: no remote URLs for the cover, plus its install state. */
|
|
44
|
+
export interface CatalogPluginWire extends Omit<CatalogPlugin, "coverSource"> {
|
|
45
|
+
/** Same-origin cover URL, absent when the catalog has none. */
|
|
46
|
+
cover?: string;
|
|
47
|
+
installedVersion?: string;
|
|
48
|
+
updateAvailable: boolean;
|
|
49
|
+
compatible: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface PluginCatalogResponse {
|
|
52
|
+
catalog: {
|
|
53
|
+
generatedAt?: string;
|
|
54
|
+
categories: Array<{
|
|
55
|
+
id: string;
|
|
56
|
+
count: number;
|
|
57
|
+
}>;
|
|
58
|
+
plugins: CatalogPluginWire[];
|
|
59
|
+
};
|
|
60
|
+
meta: CatalogResult["meta"];
|
|
61
|
+
installed: Array<{
|
|
62
|
+
package: string;
|
|
63
|
+
version: string;
|
|
64
|
+
}>;
|
|
65
|
+
sdkVersion?: string;
|
|
66
|
+
}
|
|
67
|
+
export interface PluginInstallResponse {
|
|
68
|
+
package: string;
|
|
69
|
+
installedVersion: string;
|
|
70
|
+
alreadyInstalled: boolean;
|
|
71
|
+
/** Replaced an installed version (update, reinstall or downgrade). */
|
|
72
|
+
updated: boolean;
|
|
73
|
+
previousVersion?: string;
|
|
74
|
+
/** The plugin loads when the workspace's current runs finish. */
|
|
75
|
+
restartPending: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The package's code was already imported in this process, and Node keeps it for the process's
|
|
78
|
+
* lifetime: the new version only runs after Alisio restarts.
|
|
79
|
+
*/
|
|
80
|
+
restartRequired: boolean;
|
|
81
|
+
}
|
|
82
|
+
export interface PluginUninstallResponse {
|
|
83
|
+
package: string;
|
|
84
|
+
removed: boolean;
|
|
85
|
+
restartPending: boolean;
|
|
86
|
+
}
|
|
87
|
+
export interface InstalledPluginPackage {
|
|
88
|
+
package: string;
|
|
89
|
+
version: string;
|
|
90
|
+
/** Ids of the plugins this package provides in the workspace (empty when it did not load). */
|
|
91
|
+
pluginIds: string[];
|
|
92
|
+
removable: boolean;
|
|
93
|
+
/** Updated on disk, but this process still runs the old code. */
|
|
94
|
+
restartRequired: boolean;
|
|
95
|
+
/** Installed from a local `.tgz`: it has no remote source to check for updates. */
|
|
96
|
+
local: boolean;
|
|
97
|
+
/** The catalog's version, for a catalog plugin that is not a local package. */
|
|
98
|
+
latestVersion?: string;
|
|
99
|
+
updateAvailable: boolean;
|
|
100
|
+
/** False when the newer version needs a newer `@alisio/sdk` than this Alisio has. */
|
|
101
|
+
updateCompatible: boolean;
|
|
102
|
+
}
|
|
103
|
+
export declare function registerPluginCatalogRoutes(router: Router, ctx: ManagementContext, recycler: WorkspaceRecycler, options: PluginCatalogRouteOptions): void;
|