andesine 0.4.0 → 0.5.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 +23 -0
- package/README.md +66 -0
- package/dist/cli.js +2533 -49
- package/dist/cli.js.map +1 -1
- package/dist/templates/extension/.env.example +5 -0
- package/dist/templates/extension/LICENSE +21 -0
- package/dist/templates/extension/README.md +33 -0
- package/dist/templates/extension/andesine.config.ts +79 -0
- package/dist/templates/extension/andesine.keys.json +4 -0
- package/dist/templates/extension/gitignore +4 -0
- package/dist/templates/extension/package.template.json +27 -0
- package/dist/templates/extension/src/backend/server.ts +85 -0
- package/dist/templates/extension/src/frontend/about-section.tsx +47 -0
- package/dist/templates/extension/src/frontend/bookmarks-panel.tsx +67 -0
- package/dist/templates/extension/src/frontend/hello-panel.tsx +61 -0
- package/dist/templates/extension/src/frontend/index.tsx +6 -0
- package/dist/templates/extension/src/frontend/note-view.tsx +54 -0
- package/dist/templates/extension/src/frontend/shout-action.tsx +57 -0
- package/dist/templates/extension/tsconfig.json +16 -0
- package/package.json +18 -7
package/dist/cli.js
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import { Command, Option } from "@commander-js/extra-typings";
|
|
3
|
-
import { chmod, cp, link, lstat, mkdir, open, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { access, chmod, cp, link, lstat, mkdir, open, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import * as z from "zod";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
|
-
import { promisify, styleText } from "node:util";
|
|
8
|
+
import { promisify, stripVTControlCharacters, styleText } from "node:util";
|
|
8
9
|
import { confirm, intro, isCancel, log, multiselect, note, select, spinner, text } from "@clack/prompts";
|
|
9
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID } from "node:crypto";
|
|
10
11
|
import { execFile } from "node:child_process";
|
|
11
|
-
import { constants } from "node:fs";
|
|
12
|
-
import { setTimeout } from "node:timers/promises";
|
|
12
|
+
import { constants, watch } from "node:fs";
|
|
13
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
13
14
|
import lockfile from "proper-lockfile";
|
|
14
15
|
import { AndesineAPIError, AndesineStreamProtocolError, createClient, paginate, paginatePages } from "@andesine/sdk";
|
|
15
16
|
import openBrowser from "open";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
18
|
import { Readable } from "node:stream";
|
|
18
19
|
import { pipeline } from "node:stream/promises";
|
|
20
|
+
import basex from "base-x";
|
|
21
|
+
import { JSON_SCHEMA_REGISTRY } from "@orpc/zod/zod4";
|
|
22
|
+
import { transformAsync } from "@babel/core";
|
|
23
|
+
import { build } from "rolldown";
|
|
24
|
+
import { transformSync } from "rolldown/utils";
|
|
25
|
+
import { createGenerator, definePreset, presetIcons, presetWind3 } from "unocss";
|
|
26
|
+
import { colors } from "unocss/preset-wind3";
|
|
27
|
+
import { FileSystemIconLoader } from "@iconify/utils/lib/loader/node-loaders";
|
|
19
28
|
import { CommanderError } from "commander";
|
|
20
29
|
//#region src/errors.ts
|
|
21
30
|
/** Process exit codes shared by commands and the executable. */
|
|
@@ -42,7 +51,7 @@ var CancelledError = class extends CLIError {
|
|
|
42
51
|
};
|
|
43
52
|
//#endregion
|
|
44
53
|
//#region package.json
|
|
45
|
-
var version = "0.
|
|
54
|
+
var version = "0.5.0";
|
|
46
55
|
//#endregion
|
|
47
56
|
//#region src/config/files.ts
|
|
48
57
|
/** Read declarative JSON only. Missing optional files are distinct from invalid files. */
|
|
@@ -478,7 +487,7 @@ const withStateLock = async (name, signal, run) => {
|
|
|
478
487
|
} catch (error) {
|
|
479
488
|
if (error.code !== "ELOCKED") throw new CLIError("Cannot lock CLI storage.");
|
|
480
489
|
if (Date.now() >= deadline) throw new CLIError("Another CLI process is using these credentials. Try again later.");
|
|
481
|
-
await setTimeout(100, void 0, { signal });
|
|
490
|
+
await setTimeout$1(100, void 0, { signal });
|
|
482
491
|
}
|
|
483
492
|
}
|
|
484
493
|
const assertOwned = () => {
|
|
@@ -593,7 +602,7 @@ const startDeviceLogin = async (baseURL, signal) => {
|
|
|
593
602
|
const pollDeviceLogin = async (baseURL, device, expiresAt, signal) => {
|
|
594
603
|
let interval = device.interval * 1e3;
|
|
595
604
|
while (Date.now() < expiresAt) {
|
|
596
|
-
await setTimeout(Math.min(interval, Math.max(expiresAt - Date.now(), 0)), void 0, { signal });
|
|
605
|
+
await setTimeout$1(Math.min(interval, Math.max(expiresAt - Date.now(), 0)), void 0, { signal });
|
|
597
606
|
if (Date.now() >= expiresAt) break;
|
|
598
607
|
try {
|
|
599
608
|
return parseResponse(tokenResponseSchema, await oauthRequest(baseURL, "oauth2/token", {
|
|
@@ -634,35 +643,12 @@ const revokeAccess = async (baseURL, refreshToken, signal) => {
|
|
|
634
643
|
};
|
|
635
644
|
//#endregion
|
|
636
645
|
//#region src/auth/client.ts
|
|
637
|
-
/**
|
|
638
|
-
const
|
|
646
|
+
/** The saved OAuth access token of the selected profile, refreshed when it expires soon. */
|
|
647
|
+
const getAccessToken = async (context) => {
|
|
639
648
|
const { config, signal } = context;
|
|
640
|
-
const apiKey = process.env.ANDESINE_API_KEY;
|
|
641
|
-
const headers = config.workspaceID ? { "x-workspace-id": config.workspaceID } : void 0;
|
|
642
|
-
const fetcher = (input, init) => fetch(input, {
|
|
643
|
-
...init,
|
|
644
|
-
redirect: "error",
|
|
645
|
-
signal: AbortSignal.any([
|
|
646
|
-
signal,
|
|
647
|
-
...init?.signal ? [init.signal] : [],
|
|
648
|
-
...input instanceof Request ? [input.signal] : []
|
|
649
|
-
])
|
|
650
|
-
});
|
|
651
|
-
if (apiKey !== void 0) {
|
|
652
|
-
if (!apiKey.trim()) throw new CLIError("ANDESINE_API_KEY is empty. Set it or remove it before using saved credentials.");
|
|
653
|
-
return {
|
|
654
|
-
client: createClient({
|
|
655
|
-
baseURL: config.baseURL,
|
|
656
|
-
apiKey,
|
|
657
|
-
headers,
|
|
658
|
-
fetch: fetcher
|
|
659
|
-
}),
|
|
660
|
-
source: "environment"
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
649
|
const reference = config.profile?.credentialRef;
|
|
664
650
|
if (!reference) throw new CLIError("No credentials for this instance. Run andesine auth login or set ANDESINE_API_KEY.");
|
|
665
|
-
|
|
651
|
+
return withStateLock(reference, signal, async (assertOwned) => {
|
|
666
652
|
const store = await credentialStore(reference, signal);
|
|
667
653
|
const credentials = await store.get();
|
|
668
654
|
if (!credentials) throw new CLIError("Saved credentials were not found. Run auth login again.");
|
|
@@ -692,6 +678,35 @@ const authenticatedClient = async (context) => {
|
|
|
692
678
|
throw new CLIError("Could not complete the token exchange. Run auth login again; the previous refresh token will not be reused.");
|
|
693
679
|
}
|
|
694
680
|
});
|
|
681
|
+
};
|
|
682
|
+
/** Select one credential source explicitly; never fall back after an authentication error. */
|
|
683
|
+
const authenticatedClient = async (context) => {
|
|
684
|
+
const { config, signal } = context;
|
|
685
|
+
const apiKey = process.env.ANDESINE_API_KEY;
|
|
686
|
+
const headers = config.workspaceID ? { "x-workspace-id": config.workspaceID } : void 0;
|
|
687
|
+
const fetcher = (input, init) => fetch(input, {
|
|
688
|
+
...init,
|
|
689
|
+
redirect: "error",
|
|
690
|
+
signal: AbortSignal.any([
|
|
691
|
+
signal,
|
|
692
|
+
...init?.signal ? [init.signal] : [],
|
|
693
|
+
...input instanceof Request ? [input.signal] : []
|
|
694
|
+
])
|
|
695
|
+
});
|
|
696
|
+
if (apiKey !== void 0) {
|
|
697
|
+
if (!apiKey.trim()) throw new CLIError("ANDESINE_API_KEY is empty. Set it or remove it before using saved credentials.");
|
|
698
|
+
return {
|
|
699
|
+
client: createClient({
|
|
700
|
+
baseURL: config.baseURL,
|
|
701
|
+
apiKey,
|
|
702
|
+
headers,
|
|
703
|
+
fetch: fetcher
|
|
704
|
+
}),
|
|
705
|
+
source: "environment"
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
const accessToken = await getAccessToken(context);
|
|
709
|
+
const reference = config.profile.credentialRef;
|
|
695
710
|
return {
|
|
696
711
|
client: createClient({
|
|
697
712
|
baseURL: config.baseURL,
|
|
@@ -1259,7 +1274,7 @@ const json = (value) => JSON.stringify(value).replace(/\u2028/g, "\\u2028").repl
|
|
|
1259
1274
|
const literal = (value) => json(value);
|
|
1260
1275
|
const union = (values) => [...new Set(values)].sort(compare).join(" | ") || "never";
|
|
1261
1276
|
const tuple = (values) => `[${values.join(", ")}]`;
|
|
1262
|
-
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
1277
|
+
const hash$1 = (value) => createHash("sha256").update(value).digest("hex");
|
|
1263
1278
|
const identifier = (label) => {
|
|
1264
1279
|
const name = (label.normalize("NFKC").match(/[\p{L}\p{N}\p{M}]+/gu) ?? []).map((word) => {
|
|
1265
1280
|
const [first, ...rest] = Array.from(word);
|
|
@@ -1279,7 +1294,7 @@ const names = (items, suffix) => {
|
|
|
1279
1294
|
}
|
|
1280
1295
|
for (const item of sorted) {
|
|
1281
1296
|
const base = `${identifier(item.label)}${suffix}`;
|
|
1282
|
-
const digest = hash(item.key);
|
|
1297
|
+
const digest = hash$1(item.key);
|
|
1283
1298
|
let name = counts.get(base) === 1 ? base : `${base}_${digest.slice(0, 8)}`;
|
|
1284
1299
|
let length = 8;
|
|
1285
1300
|
while (used.has(name)) {
|
|
@@ -1485,7 +1500,7 @@ const generateTypes = (metadata, options = {}) => {
|
|
|
1485
1500
|
const revision = revisions.get(id);
|
|
1486
1501
|
if (!revision) throw new Error(`Missing schema revision ${literal(id)}.`);
|
|
1487
1502
|
const fields = schemaFields(revision);
|
|
1488
|
-
const shapeName = `SchemaFields_${hash(fields)}`;
|
|
1503
|
+
const shapeName = `SchemaFields_${hash$1(fields)}`;
|
|
1489
1504
|
const revisionName = revisionNames.get(id);
|
|
1490
1505
|
shapes.set(shapeName, fields);
|
|
1491
1506
|
interfaces.push(`export interface ${revisionName} extends ${shapeName} {
|
|
@@ -1730,7 +1745,7 @@ const executeTypes = async (context, options = {}, emitResult = true) => {
|
|
|
1730
1745
|
}
|
|
1731
1746
|
first = false;
|
|
1732
1747
|
}
|
|
1733
|
-
await setTimeout(Math.min(delay, 2147483647), void 0, { signal });
|
|
1748
|
+
await setTimeout$1(Math.min(delay, 2147483647), void 0, { signal });
|
|
1734
1749
|
}
|
|
1735
1750
|
};
|
|
1736
1751
|
//#endregion
|
|
@@ -1869,7 +1884,6 @@ const init = async (options, signal) => {
|
|
|
1869
1884
|
};
|
|
1870
1885
|
//#endregion
|
|
1871
1886
|
//#region src/pages/init.ts
|
|
1872
|
-
const templateDirectory = fileURLToPath(new URL("./templates/pages/", import.meta.url));
|
|
1873
1887
|
const isEmptyDirectory = async (directory) => {
|
|
1874
1888
|
try {
|
|
1875
1889
|
return (await readdir(directory)).length === 0;
|
|
@@ -1879,24 +1893,33 @@ const isEmptyDirectory = async (directory) => {
|
|
|
1879
1893
|
}
|
|
1880
1894
|
};
|
|
1881
1895
|
/** Package names allow lowercase letters, digits, and `-._~` only. */
|
|
1882
|
-
const toPackageName = (directory) => {
|
|
1883
|
-
return path.basename(directory).toLowerCase().replace(/[^a-z\d._~-]+/g, "-").replace(/^[._-]+|[-]+$/g, "") ||
|
|
1896
|
+
const toPackageName = (directory, fallback) => {
|
|
1897
|
+
return path.basename(directory).toLowerCase().replace(/[^a-z\d._~-]+/g, "-").replace(/^[._-]+|[-]+$/g, "") || fallback;
|
|
1884
1898
|
};
|
|
1885
|
-
/**
|
|
1886
|
-
|
|
1899
|
+
/**
|
|
1900
|
+
* Copies a template that the build places next to the bundled CLI (see `scripts/templates.ts`)
|
|
1901
|
+
* into a new or empty directory, named after the directory.
|
|
1902
|
+
*/
|
|
1903
|
+
const copyTemplate = async (template, directory, output) => {
|
|
1904
|
+
const source = fileURLToPath(new URL(`./templates/${template}/`, import.meta.url));
|
|
1887
1905
|
const target = path.resolve(directory);
|
|
1888
|
-
const manifestPath = path.join(target, "package.json");
|
|
1889
1906
|
if (!await isEmptyDirectory(target)) throw new CLIError(`${directory} is not empty. Choose a new or empty directory.`, exitCodes.usage);
|
|
1890
1907
|
await output.progress("Copying the template", async () => {
|
|
1891
|
-
const manifest = JSON.parse(await readFile(path.join(
|
|
1892
|
-
|
|
1908
|
+
const manifest = JSON.parse(await readFile(path.join(source, "package.template.json"), "utf8"));
|
|
1909
|
+
const name = toPackageName(target, manifest.name);
|
|
1910
|
+
await cp(source, target, { recursive: true });
|
|
1893
1911
|
await rename(path.join(target, "gitignore"), path.join(target, ".gitignore"));
|
|
1894
1912
|
await rm(path.join(target, "package.template.json"));
|
|
1895
|
-
await writeFile(
|
|
1913
|
+
await writeFile(path.join(target, "package.json"), `${JSON.stringify({
|
|
1896
1914
|
...manifest,
|
|
1897
|
-
name
|
|
1915
|
+
name
|
|
1898
1916
|
}, null, 2)}\n`);
|
|
1899
1917
|
}, "Template copied");
|
|
1918
|
+
return target;
|
|
1919
|
+
};
|
|
1920
|
+
/** Creates an Andesine Pages site from the template in an empty directory. */
|
|
1921
|
+
const initPages = async (directory, output) => {
|
|
1922
|
+
const target = await copyTemplate("pages", directory, output);
|
|
1900
1923
|
await output.note("Next steps", {
|
|
1901
1924
|
"Open the site": `cd ${path.relative(process.cwd(), target) || "."}`,
|
|
1902
1925
|
"Install": "npm install",
|
|
@@ -8606,6 +8629,7 @@ const schemas = {
|
|
|
8606
8629
|
"roles",
|
|
8607
8630
|
"webhooks",
|
|
8608
8631
|
"read:webhooks",
|
|
8632
|
+
"extensions",
|
|
8609
8633
|
"workspace"
|
|
8610
8634
|
],
|
|
8611
8635
|
type: "string"
|
|
@@ -9024,6 +9048,2465 @@ const registerTypes = (program, signal) => {
|
|
|
9024
9048
|
await executeTypes(await createCommandContext(globals, signal), options);
|
|
9025
9049
|
});
|
|
9026
9050
|
};
|
|
9051
|
+
basex("0123456789abcdef");
|
|
9052
|
+
basex("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
|
|
9053
|
+
//#endregion
|
|
9054
|
+
//#region ../../private/contracts/src/primitives/id.ts
|
|
9055
|
+
const ID_REGEX = /^(?:\w+?_[A-Za-z\d]{1,22})$/;
|
|
9056
|
+
const id = (options) => {
|
|
9057
|
+
return z.string().regex(ID_REGEX, {
|
|
9058
|
+
error: "invalid id",
|
|
9059
|
+
...options
|
|
9060
|
+
});
|
|
9061
|
+
};
|
|
9062
|
+
const publicID = (prefix, options) => {
|
|
9063
|
+
return z.string().regex(new RegExp(`^${prefix}_[A-Za-z\\d]{1,22}$`), {
|
|
9064
|
+
error: `invalid ${prefix} id`,
|
|
9065
|
+
...options
|
|
9066
|
+
});
|
|
9067
|
+
};
|
|
9068
|
+
//#endregion
|
|
9069
|
+
//#region ../../private/contracts/src/primitives/unique.ts
|
|
9070
|
+
/** Whether every value is distinct. */
|
|
9071
|
+
const uniqueItems = (values) => new Set(values).size === values.length;
|
|
9072
|
+
//#endregion
|
|
9073
|
+
//#region ../../private/contracts/src/webhooks/catalog-definitions.ts
|
|
9074
|
+
const webhookEventNames = [
|
|
9075
|
+
"entry.created",
|
|
9076
|
+
"entry.updated",
|
|
9077
|
+
"entry.content_saved",
|
|
9078
|
+
"entry.moved",
|
|
9079
|
+
"entry.deleted",
|
|
9080
|
+
"entry.restored",
|
|
9081
|
+
"collection.created",
|
|
9082
|
+
"collection.updated",
|
|
9083
|
+
"collection.moved",
|
|
9084
|
+
"collection.deleted",
|
|
9085
|
+
"collection.restored",
|
|
9086
|
+
"publishing.channel_advanced",
|
|
9087
|
+
"publishing.channel_created",
|
|
9088
|
+
"publishing.channel_deleted"
|
|
9089
|
+
];
|
|
9090
|
+
const webhookReadPermissions = [
|
|
9091
|
+
"read:entries",
|
|
9092
|
+
"read:collections",
|
|
9093
|
+
"read:publishing"
|
|
9094
|
+
];
|
|
9095
|
+
const webhookEventDefinitions = {
|
|
9096
|
+
"entry.created": {
|
|
9097
|
+
label: "Entry created",
|
|
9098
|
+
description: "An entry and its initial content were saved. Matches the committed collection.",
|
|
9099
|
+
category: "entries",
|
|
9100
|
+
requiredPermissions: ["read:entries"],
|
|
9101
|
+
filters: ["collections"]
|
|
9102
|
+
},
|
|
9103
|
+
"entry.updated": {
|
|
9104
|
+
label: "Entry metadata updated",
|
|
9105
|
+
description: "Entry metadata changed. Content and location changes have separate events.",
|
|
9106
|
+
category: "entries",
|
|
9107
|
+
requiredPermissions: ["read:entries"],
|
|
9108
|
+
filters: ["collections"]
|
|
9109
|
+
},
|
|
9110
|
+
"entry.content_saved": {
|
|
9111
|
+
label: "Entry content saved",
|
|
9112
|
+
description: "Changed content was saved and is available through the API. No-op saves do not qualify.",
|
|
9113
|
+
category: "entries",
|
|
9114
|
+
requiredPermissions: ["read:entries"],
|
|
9115
|
+
filters: ["collections"]
|
|
9116
|
+
},
|
|
9117
|
+
"entry.moved": {
|
|
9118
|
+
label: "Entry moved",
|
|
9119
|
+
description: "An entry was moved or reordered, directly or with an ancestor. Matches either location; unauthorized location details are hidden.",
|
|
9120
|
+
category: "entries",
|
|
9121
|
+
requiredPermissions: ["read:entries"],
|
|
9122
|
+
filters: ["collections"]
|
|
9123
|
+
},
|
|
9124
|
+
"entry.deleted": {
|
|
9125
|
+
label: "Entry deleted",
|
|
9126
|
+
description: "An entry was logically deleted. Matches its former collection; physical cleanup emits no event.",
|
|
9127
|
+
category: "entries",
|
|
9128
|
+
requiredPermissions: ["read:entries"],
|
|
9129
|
+
filters: ["collections"]
|
|
9130
|
+
},
|
|
9131
|
+
"entry.restored": {
|
|
9132
|
+
label: "Entry restored",
|
|
9133
|
+
description: "A deleted entry was restored. Matches its restored collection.",
|
|
9134
|
+
category: "entries",
|
|
9135
|
+
requiredPermissions: ["read:entries"],
|
|
9136
|
+
filters: ["collections"]
|
|
9137
|
+
},
|
|
9138
|
+
"collection.created": {
|
|
9139
|
+
label: "Collection created",
|
|
9140
|
+
description: "A collection was created. A selected subtree includes its root collection.",
|
|
9141
|
+
category: "collections",
|
|
9142
|
+
requiredPermissions: ["read:collections"],
|
|
9143
|
+
filters: ["collections"]
|
|
9144
|
+
},
|
|
9145
|
+
"collection.updated": {
|
|
9146
|
+
label: "Collection updated",
|
|
9147
|
+
description: "Collection metadata changed. Matches the committed subtree and its current access restrictions.",
|
|
9148
|
+
category: "collections",
|
|
9149
|
+
requiredPermissions: ["read:collections"],
|
|
9150
|
+
filters: ["collections"]
|
|
9151
|
+
},
|
|
9152
|
+
"collection.moved": {
|
|
9153
|
+
label: "Collection moved",
|
|
9154
|
+
description: "A collection was moved or reordered. Affected descendants have their own events; either location can match.",
|
|
9155
|
+
category: "collections",
|
|
9156
|
+
requiredPermissions: ["read:collections"],
|
|
9157
|
+
filters: ["collections"]
|
|
9158
|
+
},
|
|
9159
|
+
"collection.deleted": {
|
|
9160
|
+
label: "Collection deleted",
|
|
9161
|
+
description: "A collection was logically deleted. Matches its former subtree; deleted descendants have their own events.",
|
|
9162
|
+
category: "collections",
|
|
9163
|
+
requiredPermissions: ["read:collections"],
|
|
9164
|
+
filters: ["collections"]
|
|
9165
|
+
},
|
|
9166
|
+
"collection.restored": {
|
|
9167
|
+
label: "Collection restored",
|
|
9168
|
+
description: "A deleted collection was restored. Matches its restored subtree.",
|
|
9169
|
+
category: "collections",
|
|
9170
|
+
requiredPermissions: ["read:collections"],
|
|
9171
|
+
filters: ["collections"]
|
|
9172
|
+
},
|
|
9173
|
+
"publishing.channel_advanced": {
|
|
9174
|
+
label: "Channel snapshot advanced",
|
|
9175
|
+
description: "One channel snapshot changed. Matches changes inside the webhook scope in either snapshot, including removals, and the channel filter. Snapshot references do not grant content access.",
|
|
9176
|
+
category: "publishing",
|
|
9177
|
+
requiredPermissions: ["read:publishing"],
|
|
9178
|
+
filters: ["collections", "channels"]
|
|
9179
|
+
},
|
|
9180
|
+
"publishing.channel_created": {
|
|
9181
|
+
label: "Channel created",
|
|
9182
|
+
description: "A channel and its initial snapshot were created. The collection scope does not apply.",
|
|
9183
|
+
category: "publishing",
|
|
9184
|
+
requiredPermissions: ["read:publishing"],
|
|
9185
|
+
filters: ["channels"]
|
|
9186
|
+
},
|
|
9187
|
+
"publishing.channel_deleted": {
|
|
9188
|
+
label: "Channel deleted",
|
|
9189
|
+
description: "A channel was deleted. The collection scope does not apply; its final snapshot retains the existing workspace expiry policy.",
|
|
9190
|
+
category: "publishing",
|
|
9191
|
+
requiredPermissions: ["read:publishing"],
|
|
9192
|
+
filters: ["channels"]
|
|
9193
|
+
}
|
|
9194
|
+
};
|
|
9195
|
+
//#endregion
|
|
9196
|
+
//#region ../../private/contracts/src/webhooks/events.ts
|
|
9197
|
+
const webhookChannelCodeType = z.string().min(1).max(50);
|
|
9198
|
+
z.enum(webhookReadPermissions);
|
|
9199
|
+
const nameType = z.string().min(1).max(300);
|
|
9200
|
+
const parentType = publicID("coll").nullable();
|
|
9201
|
+
const contentHashType = z.string().regex(/^[a-f\d]{64}$/);
|
|
9202
|
+
const entrySubjectType = z.strictObject({
|
|
9203
|
+
kind: z.literal("entry"),
|
|
9204
|
+
id: publicID("ent")
|
|
9205
|
+
});
|
|
9206
|
+
const collectionSubjectType = z.strictObject({
|
|
9207
|
+
kind: z.literal("collection"),
|
|
9208
|
+
id: publicID("coll")
|
|
9209
|
+
});
|
|
9210
|
+
const channelSubjectType = z.strictObject({
|
|
9211
|
+
kind: z.literal("channel"),
|
|
9212
|
+
code: webhookChannelCodeType
|
|
9213
|
+
});
|
|
9214
|
+
const envelopeType = z.strictObject({
|
|
9215
|
+
id: publicID("whevt"),
|
|
9216
|
+
operationID: publicID("whop"),
|
|
9217
|
+
schemaVersion: z.literal(1),
|
|
9218
|
+
occurredAt: z.iso.datetime(),
|
|
9219
|
+
workspaceID: publicID("ws"),
|
|
9220
|
+
test: z.boolean()
|
|
9221
|
+
});
|
|
9222
|
+
const visibleLocationType = z.strictObject({
|
|
9223
|
+
visibility: z.literal("visible"),
|
|
9224
|
+
parentID: parentType.describe("Null means the workspace root, never a hidden parent"),
|
|
9225
|
+
order: z.string().min(1).max(255).optional()
|
|
9226
|
+
});
|
|
9227
|
+
const hiddenLocationType = z.strictObject({ visibility: z.literal("hidden") });
|
|
9228
|
+
const moveReasonType = z.enum([
|
|
9229
|
+
"direct",
|
|
9230
|
+
"ancestor_moved",
|
|
9231
|
+
"reordered"
|
|
9232
|
+
]);
|
|
9233
|
+
const moveDataType = z.discriminatedUnion("scopeTransition", [
|
|
9234
|
+
z.strictObject({
|
|
9235
|
+
scopeTransition: z.literal("within"),
|
|
9236
|
+
reason: moveReasonType,
|
|
9237
|
+
from: visibleLocationType,
|
|
9238
|
+
to: visibleLocationType
|
|
9239
|
+
}),
|
|
9240
|
+
z.strictObject({
|
|
9241
|
+
scopeTransition: z.literal("entered"),
|
|
9242
|
+
reason: moveReasonType,
|
|
9243
|
+
from: hiddenLocationType,
|
|
9244
|
+
to: visibleLocationType
|
|
9245
|
+
}),
|
|
9246
|
+
z.strictObject({
|
|
9247
|
+
scopeTransition: z.literal("left"),
|
|
9248
|
+
reason: moveReasonType,
|
|
9249
|
+
from: visibleLocationType,
|
|
9250
|
+
to: hiddenLocationType
|
|
9251
|
+
})
|
|
9252
|
+
]);
|
|
9253
|
+
const entryDataType = z.strictObject({
|
|
9254
|
+
name: nameType,
|
|
9255
|
+
collectionID: parentType
|
|
9256
|
+
});
|
|
9257
|
+
const collectionDataType = z.strictObject({
|
|
9258
|
+
name: nameType,
|
|
9259
|
+
parentID: parentType
|
|
9260
|
+
});
|
|
9261
|
+
const entryEventType = envelopeType.extend({ subject: entrySubjectType });
|
|
9262
|
+
const collectionEventType = envelopeType.extend({ subject: collectionSubjectType });
|
|
9263
|
+
const channelEventType = envelopeType.extend({ subject: channelSubjectType });
|
|
9264
|
+
const webhookEventSchemas = {
|
|
9265
|
+
"entry.created": entryEventType.extend({
|
|
9266
|
+
type: z.literal("entry.created"),
|
|
9267
|
+
data: entryDataType.extend({ contentHash: contentHashType.optional() })
|
|
9268
|
+
}),
|
|
9269
|
+
"entry.updated": entryEventType.extend({
|
|
9270
|
+
type: z.literal("entry.updated"),
|
|
9271
|
+
data: z.strictObject({
|
|
9272
|
+
collectionID: parentType,
|
|
9273
|
+
changedFields: z.array(z.literal("name")).length(1)
|
|
9274
|
+
})
|
|
9275
|
+
}),
|
|
9276
|
+
"entry.content_saved": entryEventType.extend({
|
|
9277
|
+
type: z.literal("entry.content_saved"),
|
|
9278
|
+
data: z.strictObject({
|
|
9279
|
+
collectionID: parentType,
|
|
9280
|
+
contentHash: contentHashType,
|
|
9281
|
+
savedAt: z.iso.datetime()
|
|
9282
|
+
})
|
|
9283
|
+
}),
|
|
9284
|
+
"entry.moved": entryEventType.extend({
|
|
9285
|
+
type: z.literal("entry.moved"),
|
|
9286
|
+
data: moveDataType
|
|
9287
|
+
}),
|
|
9288
|
+
"entry.deleted": entryEventType.extend({
|
|
9289
|
+
type: z.literal("entry.deleted"),
|
|
9290
|
+
data: entryDataType.extend({ deletedAt: z.iso.datetime() })
|
|
9291
|
+
}),
|
|
9292
|
+
"entry.restored": entryEventType.extend({
|
|
9293
|
+
type: z.literal("entry.restored"),
|
|
9294
|
+
data: entryDataType
|
|
9295
|
+
}),
|
|
9296
|
+
"collection.created": collectionEventType.extend({
|
|
9297
|
+
type: z.literal("collection.created"),
|
|
9298
|
+
data: collectionDataType
|
|
9299
|
+
}),
|
|
9300
|
+
"collection.updated": collectionEventType.extend({
|
|
9301
|
+
type: z.literal("collection.updated"),
|
|
9302
|
+
data: z.strictObject({
|
|
9303
|
+
parentID: parentType,
|
|
9304
|
+
changedFields: z.array(z.enum([
|
|
9305
|
+
"name",
|
|
9306
|
+
"restricted",
|
|
9307
|
+
"publishingEnabled"
|
|
9308
|
+
])).min(1).max(3).refine(uniqueItems, "Changed fields must be unique").register(JSON_SCHEMA_REGISTRY, { uniqueItems: true })
|
|
9309
|
+
})
|
|
9310
|
+
}),
|
|
9311
|
+
"collection.moved": collectionEventType.extend({
|
|
9312
|
+
type: z.literal("collection.moved"),
|
|
9313
|
+
data: moveDataType
|
|
9314
|
+
}),
|
|
9315
|
+
"collection.deleted": collectionEventType.extend({
|
|
9316
|
+
type: z.literal("collection.deleted"),
|
|
9317
|
+
data: collectionDataType.extend({ deletedAt: z.iso.datetime() })
|
|
9318
|
+
}),
|
|
9319
|
+
"collection.restored": collectionEventType.extend({
|
|
9320
|
+
type: z.literal("collection.restored"),
|
|
9321
|
+
data: collectionDataType
|
|
9322
|
+
}),
|
|
9323
|
+
"publishing.channel_advanced": channelEventType.extend({
|
|
9324
|
+
type: z.literal("publishing.channel_advanced"),
|
|
9325
|
+
data: z.strictObject({
|
|
9326
|
+
previousSnapshotID: publicID("snp"),
|
|
9327
|
+
snapshotID: publicID("snp"),
|
|
9328
|
+
reason: z.enum(["publish", "unpublish"])
|
|
9329
|
+
})
|
|
9330
|
+
}),
|
|
9331
|
+
"publishing.channel_created": channelEventType.extend({
|
|
9332
|
+
type: z.literal("publishing.channel_created"),
|
|
9333
|
+
data: z.strictObject({
|
|
9334
|
+
name: z.string().min(1).max(50),
|
|
9335
|
+
builtIn: z.boolean(),
|
|
9336
|
+
snapshotID: publicID("snp")
|
|
9337
|
+
})
|
|
9338
|
+
}),
|
|
9339
|
+
"publishing.channel_deleted": channelEventType.extend({
|
|
9340
|
+
type: z.literal("publishing.channel_deleted"),
|
|
9341
|
+
data: z.strictObject({
|
|
9342
|
+
name: z.string().min(1).max(50),
|
|
9343
|
+
snapshotID: publicID("snp")
|
|
9344
|
+
})
|
|
9345
|
+
})
|
|
9346
|
+
};
|
|
9347
|
+
const webhookEventNameType = z.enum(webhookEventNames);
|
|
9348
|
+
const webhookEventType = z.discriminatedUnion("type", [
|
|
9349
|
+
webhookEventSchemas["entry.created"],
|
|
9350
|
+
webhookEventSchemas["entry.updated"],
|
|
9351
|
+
webhookEventSchemas["entry.content_saved"],
|
|
9352
|
+
webhookEventSchemas["entry.moved"],
|
|
9353
|
+
webhookEventSchemas["entry.deleted"],
|
|
9354
|
+
webhookEventSchemas["entry.restored"],
|
|
9355
|
+
webhookEventSchemas["collection.created"],
|
|
9356
|
+
webhookEventSchemas["collection.updated"],
|
|
9357
|
+
webhookEventSchemas["collection.moved"],
|
|
9358
|
+
webhookEventSchemas["collection.deleted"],
|
|
9359
|
+
webhookEventSchemas["collection.restored"],
|
|
9360
|
+
webhookEventSchemas["publishing.channel_advanced"],
|
|
9361
|
+
webhookEventSchemas["publishing.channel_created"],
|
|
9362
|
+
webhookEventSchemas["publishing.channel_deleted"]
|
|
9363
|
+
]);
|
|
9364
|
+
//#endregion
|
|
9365
|
+
//#region ../../private/contracts/src/entities/keys.ts
|
|
9366
|
+
const keyPermissionType = z.enum([
|
|
9367
|
+
"entries",
|
|
9368
|
+
"read:entries",
|
|
9369
|
+
"versions",
|
|
9370
|
+
"read:versions",
|
|
9371
|
+
"publishing",
|
|
9372
|
+
"read:publishing",
|
|
9373
|
+
"collections",
|
|
9374
|
+
"read:collections",
|
|
9375
|
+
"memberships",
|
|
9376
|
+
"read:memberships",
|
|
9377
|
+
"roles",
|
|
9378
|
+
"read:roles",
|
|
9379
|
+
"webhooks",
|
|
9380
|
+
"read:webhooks",
|
|
9381
|
+
"ai-answers"
|
|
9382
|
+
]);
|
|
9383
|
+
/** A secret key stays on servers; a publishable key is safe in browsers, for published content. */
|
|
9384
|
+
const keyKindType = z.enum(["secret", "publishable"]);
|
|
9385
|
+
z.enum(["read:publishing", "ai-answers"]);
|
|
9386
|
+
z.string().regex(/^https?:\/\/[a-z\d.-]+(?::\d{1,5})?$/i, "Use an origin, e.g. https://docs.example.com").transform((origin) => origin.toLowerCase());
|
|
9387
|
+
z.object({
|
|
9388
|
+
id: id().describe("The ID of the API key"),
|
|
9389
|
+
name: z.string().describe("The name for the API key"),
|
|
9390
|
+
kind: keyKindType.describe("Secret keys stay on servers; publishable keys work in browsers"),
|
|
9391
|
+
permissions: z.array(keyPermissionType).describe("The permissions of the API key"),
|
|
9392
|
+
collectionIDs: z.array(publicID("coll")).describe("Publishable keys: the collections, with their descendants, that the key can read; none for all"),
|
|
9393
|
+
allowedOrigins: z.array(z.string()).describe("Publishable keys: the browser origins that can use the key, with localhost"),
|
|
9394
|
+
value: z.string().nullable().describe("Publishable keys: the raw key, which is public; null for secret keys"),
|
|
9395
|
+
prefix: z.string().describe("The first characters of the raw key"),
|
|
9396
|
+
createdAt: z.iso.datetime().describe("The creation date"),
|
|
9397
|
+
updatedAt: z.iso.datetime().describe("The last update date"),
|
|
9398
|
+
expiresAt: z.iso.datetime().nullable().describe("The expiration date")
|
|
9399
|
+
});
|
|
9400
|
+
//#endregion
|
|
9401
|
+
//#region ../../private/contracts/src/permissions/requirements.ts
|
|
9402
|
+
const parsePermission = (permission) => {
|
|
9403
|
+
const [accessOrResource, readResource] = permission.split(":");
|
|
9404
|
+
return readResource ? {
|
|
9405
|
+
resource: readResource,
|
|
9406
|
+
access: accessOrResource
|
|
9407
|
+
} : {
|
|
9408
|
+
resource: accessOrResource,
|
|
9409
|
+
access: "write"
|
|
9410
|
+
};
|
|
9411
|
+
};
|
|
9412
|
+
const hasPermission = (granted, required) => {
|
|
9413
|
+
const grantedPermission = parsePermission(granted);
|
|
9414
|
+
const requiredPermission = parsePermission(required);
|
|
9415
|
+
if (grantedPermission.resource !== requiredPermission.resource) return false;
|
|
9416
|
+
return grantedPermission.access === "write" || requiredPermission.access === "read";
|
|
9417
|
+
};
|
|
9418
|
+
//#endregion
|
|
9419
|
+
//#region ../../private/contracts/src/extensions/configuration.ts
|
|
9420
|
+
const JSON_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
9421
|
+
const MAX_CONFIGURATION_STRING_LENGTH = 1e4;
|
|
9422
|
+
const MAX_CONFIGURATION_ARRAY_ITEMS = 100;
|
|
9423
|
+
const MAX_CONFIGURATION_ENUM_VALUES = 100;
|
|
9424
|
+
const MAX_CONFIGURATION_PATTERN_LENGTH = 500;
|
|
9425
|
+
const isValidPattern = (pattern) => {
|
|
9426
|
+
try {
|
|
9427
|
+
new RegExp(pattern);
|
|
9428
|
+
return true;
|
|
9429
|
+
} catch {
|
|
9430
|
+
return false;
|
|
9431
|
+
}
|
|
9432
|
+
};
|
|
9433
|
+
const fieldKeyType = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/, "Use a letter, then letters, digits, or underscores");
|
|
9434
|
+
const lengthType = z.int().min(0).max(MAX_CONFIGURATION_STRING_LENGTH);
|
|
9435
|
+
const metadata = {
|
|
9436
|
+
title: z.string().min(1).max(100).optional(),
|
|
9437
|
+
description: z.string().min(1).max(500).optional()
|
|
9438
|
+
};
|
|
9439
|
+
const stringItemType = z.strictObject({
|
|
9440
|
+
type: z.literal("string"),
|
|
9441
|
+
minLength: lengthType.optional(),
|
|
9442
|
+
maxLength: lengthType.optional(),
|
|
9443
|
+
pattern: z.string().min(1).max(MAX_CONFIGURATION_PATTERN_LENGTH).refine(isValidPattern, "Use a valid regular expression").optional(),
|
|
9444
|
+
format: z.enum(["uri", "email"]).optional()
|
|
9445
|
+
});
|
|
9446
|
+
const enumItemType = z.strictObject({
|
|
9447
|
+
type: z.literal("string"),
|
|
9448
|
+
enum: z.array(z.string().min(1).max(200)).min(1).max(MAX_CONFIGURATION_ENUM_VALUES).refine(uniqueItems, "Options must be unique")
|
|
9449
|
+
});
|
|
9450
|
+
const numberItemType = z.strictObject({
|
|
9451
|
+
type: z.enum(["number", "integer"]),
|
|
9452
|
+
minimum: z.number().optional(),
|
|
9453
|
+
maximum: z.number().optional(),
|
|
9454
|
+
exclusiveMinimum: z.number().optional(),
|
|
9455
|
+
exclusiveMaximum: z.number().optional(),
|
|
9456
|
+
multipleOf: z.number().positive().optional()
|
|
9457
|
+
});
|
|
9458
|
+
const booleanItemType = z.strictObject({ type: z.literal("boolean") });
|
|
9459
|
+
const stringFormatTypes = {
|
|
9460
|
+
uri: z.url(),
|
|
9461
|
+
email: z.email()
|
|
9462
|
+
};
|
|
9463
|
+
const itemType = z.union([
|
|
9464
|
+
stringItemType,
|
|
9465
|
+
enumItemType,
|
|
9466
|
+
numberItemType,
|
|
9467
|
+
booleanItemType
|
|
9468
|
+
]);
|
|
9469
|
+
const fieldType = z.union([
|
|
9470
|
+
stringItemType.extend({
|
|
9471
|
+
...metadata,
|
|
9472
|
+
default: z.string().optional(),
|
|
9473
|
+
secret: z.literal(true).optional(),
|
|
9474
|
+
control: z.literal("textarea").optional()
|
|
9475
|
+
}),
|
|
9476
|
+
enumItemType.extend({
|
|
9477
|
+
...metadata,
|
|
9478
|
+
default: z.string().optional()
|
|
9479
|
+
}),
|
|
9480
|
+
numberItemType.extend({
|
|
9481
|
+
...metadata,
|
|
9482
|
+
default: z.number().optional()
|
|
9483
|
+
}),
|
|
9484
|
+
booleanItemType.extend({
|
|
9485
|
+
...metadata,
|
|
9486
|
+
default: z.boolean().optional()
|
|
9487
|
+
}),
|
|
9488
|
+
z.strictObject({
|
|
9489
|
+
...metadata,
|
|
9490
|
+
type: z.literal("array"),
|
|
9491
|
+
items: itemType,
|
|
9492
|
+
minItems: z.int().min(0).max(MAX_CONFIGURATION_ARRAY_ITEMS).optional(),
|
|
9493
|
+
maxItems: z.int().min(0).max(MAX_CONFIGURATION_ARRAY_ITEMS).optional(),
|
|
9494
|
+
uniqueItems: z.boolean().optional(),
|
|
9495
|
+
default: z.array(z.union([
|
|
9496
|
+
z.string(),
|
|
9497
|
+
z.number(),
|
|
9498
|
+
z.boolean()
|
|
9499
|
+
])).optional()
|
|
9500
|
+
})
|
|
9501
|
+
]);
|
|
9502
|
+
/** Flat JSON Schema (draft 2020-12) subset that the host validates without running extension code. */
|
|
9503
|
+
const extensionConfigurationType = z.strictObject({
|
|
9504
|
+
$schema: z.literal(JSON_SCHEMA_DIALECT).optional(),
|
|
9505
|
+
type: z.literal("object"),
|
|
9506
|
+
properties: z.record(fieldKeyType, fieldType).refine((properties) => Object.keys(properties).length <= 50, `Use at most 50 fields`),
|
|
9507
|
+
required: z.array(fieldKeyType).refine(uniqueItems, "Fields must be unique").optional(),
|
|
9508
|
+
additionalProperties: z.literal(false)
|
|
9509
|
+
}).superRefine((configuration, context) => {
|
|
9510
|
+
for (const [index, key] of (configuration.required || []).entries()) if (!configuration.properties[key]) context.addIssue({
|
|
9511
|
+
code: "custom",
|
|
9512
|
+
path: ["required", index],
|
|
9513
|
+
message: "Unknown field"
|
|
9514
|
+
});
|
|
9515
|
+
for (const [key, field] of Object.entries(configuration.properties)) if ("secret" in field && field.secret && field.default !== void 0) context.addIssue({
|
|
9516
|
+
code: "custom",
|
|
9517
|
+
path: [
|
|
9518
|
+
"properties",
|
|
9519
|
+
key,
|
|
9520
|
+
"default"
|
|
9521
|
+
],
|
|
9522
|
+
message: "Secret fields cannot have defaults"
|
|
9523
|
+
});
|
|
9524
|
+
else if (field.default !== void 0) {
|
|
9525
|
+
if (!createConfigurationValueType(field).safeParse(field.default).success) context.addIssue({
|
|
9526
|
+
code: "custom",
|
|
9527
|
+
path: [
|
|
9528
|
+
"properties",
|
|
9529
|
+
key,
|
|
9530
|
+
"default"
|
|
9531
|
+
],
|
|
9532
|
+
message: "The default does not match the field"
|
|
9533
|
+
});
|
|
9534
|
+
}
|
|
9535
|
+
});
|
|
9536
|
+
const createConfigurationValueType = (field) => {
|
|
9537
|
+
if (field.type === "boolean") return z.boolean();
|
|
9538
|
+
if (field.type === "array") {
|
|
9539
|
+
const maxItems = Math.min(field.maxItems ?? MAX_CONFIGURATION_ARRAY_ITEMS, MAX_CONFIGURATION_ARRAY_ITEMS);
|
|
9540
|
+
const items = z.array(createConfigurationValueType(field.items)).min(field.minItems ?? 0).max(maxItems);
|
|
9541
|
+
return field.uniqueItems ? items.refine(uniqueItems, "Values must be unique") : items;
|
|
9542
|
+
}
|
|
9543
|
+
if ("enum" in field) return z.enum(field.enum);
|
|
9544
|
+
if (field.type === "string") {
|
|
9545
|
+
const maxLength = Math.min(field.maxLength ?? MAX_CONFIGURATION_STRING_LENGTH, MAX_CONFIGURATION_STRING_LENGTH);
|
|
9546
|
+
const value = (field.format ? stringFormatTypes[field.format] : z.string()).min(field.minLength ?? 0).max(maxLength);
|
|
9547
|
+
return field.pattern ? value.regex(new RegExp(field.pattern)) : value;
|
|
9548
|
+
}
|
|
9549
|
+
let value = field.type === "integer" ? z.int() : z.number();
|
|
9550
|
+
if (field.minimum !== void 0) value = value.gte(field.minimum);
|
|
9551
|
+
if (field.maximum !== void 0) value = value.lte(field.maximum);
|
|
9552
|
+
if (field.exclusiveMinimum !== void 0) value = value.gt(field.exclusiveMinimum);
|
|
9553
|
+
if (field.exclusiveMaximum !== void 0) value = value.lt(field.exclusiveMaximum);
|
|
9554
|
+
if (field.multipleOf !== void 0) value = value.multipleOf(field.multipleOf);
|
|
9555
|
+
return value;
|
|
9556
|
+
};
|
|
9557
|
+
//#endregion
|
|
9558
|
+
//#region ../../private/document/src/element.ts
|
|
9559
|
+
const ELEMENT_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
9560
|
+
const ELEMENT_TAG_NAME = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)*$/;
|
|
9561
|
+
const OBJECT_KEY = /^[$_\p{ID_Start}][$\u200c\u200d_\p{ID_Continue}]*$/u;
|
|
9562
|
+
const parseElementValue = (source) => {
|
|
9563
|
+
const json = source.replace(/"(?:\\[\s\S]|[^"\\])*"|[$_\p{ID_Start}][$\u200c\u200d_\p{ID_Continue}]*/gu, (token, offset) => {
|
|
9564
|
+
if (!token.startsWith("\"") && /^\s*:/.test(source.slice(offset + token.length))) return JSON.stringify(token);
|
|
9565
|
+
return token;
|
|
9566
|
+
});
|
|
9567
|
+
return JSON.parse(json);
|
|
9568
|
+
};
|
|
9569
|
+
const ELEMENT_BLOCKS = [
|
|
9570
|
+
"paragraph",
|
|
9571
|
+
"heading",
|
|
9572
|
+
"blockquote",
|
|
9573
|
+
"bulletList",
|
|
9574
|
+
"orderedList",
|
|
9575
|
+
"taskList",
|
|
9576
|
+
"horizontalRule",
|
|
9577
|
+
"codeBlock",
|
|
9578
|
+
"table",
|
|
9579
|
+
"image",
|
|
9580
|
+
"element"
|
|
9581
|
+
];
|
|
9582
|
+
const ELEMENT_STRUCTURE = [
|
|
9583
|
+
"listItem",
|
|
9584
|
+
"taskItem",
|
|
9585
|
+
"tableRow",
|
|
9586
|
+
"tableCell",
|
|
9587
|
+
"tableHeader",
|
|
9588
|
+
"text",
|
|
9589
|
+
"hardBreak"
|
|
9590
|
+
];
|
|
9591
|
+
const isElementValue = (value) => {
|
|
9592
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
9593
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
9594
|
+
if (Array.isArray(value)) return Array.from(value).every(isElementValue);
|
|
9595
|
+
if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false;
|
|
9596
|
+
return Object.values(value).every(isElementValue);
|
|
9597
|
+
};
|
|
9598
|
+
const getElementData = (attrs) => {
|
|
9599
|
+
const { name, props, selfClosing } = attrs;
|
|
9600
|
+
if (typeof name !== "string" || !ELEMENT_TAG_NAME.test(name)) throw new Error("Invalid Element name");
|
|
9601
|
+
if (!props || Array.isArray(props) || typeof props !== "object" || !isElementValue(props)) throw new Error("Element props must be a JSON object");
|
|
9602
|
+
if (Object.keys(props).some((key) => !ELEMENT_NAME.test(key))) throw new Error("Invalid Element prop name");
|
|
9603
|
+
if (typeof selfClosing !== "boolean") throw new Error("Invalid Element closing state");
|
|
9604
|
+
return {
|
|
9605
|
+
name,
|
|
9606
|
+
props,
|
|
9607
|
+
selfClosing
|
|
9608
|
+
};
|
|
9609
|
+
};
|
|
9610
|
+
const parseElement = (source) => {
|
|
9611
|
+
const props = {};
|
|
9612
|
+
const fail = () => {
|
|
9613
|
+
throw new Error("Invalid opening tag");
|
|
9614
|
+
};
|
|
9615
|
+
let position = 0;
|
|
9616
|
+
const whitespace = () => {
|
|
9617
|
+
while (/\s/.test(source[position] || "") && position < source.length) position += 1;
|
|
9618
|
+
};
|
|
9619
|
+
const identifier = (tag = false) => {
|
|
9620
|
+
const start = position;
|
|
9621
|
+
while ((tag ? /[A-Za-z0-9_.-]/ : /[A-Za-z0-9_-]/).test(source[position] || "") && position < source.length) position += 1;
|
|
9622
|
+
const value = source.slice(start, position);
|
|
9623
|
+
if (!(tag ? ELEMENT_TAG_NAME : ELEMENT_NAME).test(value)) fail();
|
|
9624
|
+
return value;
|
|
9625
|
+
};
|
|
9626
|
+
const quoted = () => {
|
|
9627
|
+
const start = position++;
|
|
9628
|
+
let escaped = false;
|
|
9629
|
+
while (position < source.length) {
|
|
9630
|
+
const character = source[position++];
|
|
9631
|
+
if (escaped) {
|
|
9632
|
+
escaped = false;
|
|
9633
|
+
continue;
|
|
9634
|
+
}
|
|
9635
|
+
if (character === "\\") {
|
|
9636
|
+
escaped = true;
|
|
9637
|
+
continue;
|
|
9638
|
+
}
|
|
9639
|
+
if (character === "\"") return JSON.parse(source.slice(start, position));
|
|
9640
|
+
}
|
|
9641
|
+
return fail();
|
|
9642
|
+
};
|
|
9643
|
+
whitespace();
|
|
9644
|
+
if (source[position++] !== "<") fail();
|
|
9645
|
+
const name = identifier(true);
|
|
9646
|
+
while (position < source.length) {
|
|
9647
|
+
const beforeSpace = position;
|
|
9648
|
+
whitespace();
|
|
9649
|
+
if (source[position] === ">" || source.slice(position, position + 2) === "/>") break;
|
|
9650
|
+
if (position === beforeSpace) fail();
|
|
9651
|
+
const key = identifier();
|
|
9652
|
+
let value = true;
|
|
9653
|
+
const afterKey = position;
|
|
9654
|
+
whitespace();
|
|
9655
|
+
if (source[position] === "=") {
|
|
9656
|
+
position += 1;
|
|
9657
|
+
whitespace();
|
|
9658
|
+
if (source[position] === "\"") value = quoted();
|
|
9659
|
+
else if (source[position] === "{") {
|
|
9660
|
+
const start = ++position;
|
|
9661
|
+
const delimiters = ["}"];
|
|
9662
|
+
while (position < source.length && delimiters.length) {
|
|
9663
|
+
const character = source[position];
|
|
9664
|
+
if (character === "\"") {
|
|
9665
|
+
quoted();
|
|
9666
|
+
continue;
|
|
9667
|
+
}
|
|
9668
|
+
if (character === "{") delimiters.push("}");
|
|
9669
|
+
if (character === "[") delimiters.push("]");
|
|
9670
|
+
if (character === "}" || character === "]") {
|
|
9671
|
+
if (delimiters.pop() !== character) fail();
|
|
9672
|
+
if (!delimiters.length) break;
|
|
9673
|
+
}
|
|
9674
|
+
position += 1;
|
|
9675
|
+
}
|
|
9676
|
+
if (delimiters.length || source[position] !== "}") fail();
|
|
9677
|
+
value = parseElementValue(source.slice(start, position));
|
|
9678
|
+
position += 1;
|
|
9679
|
+
} else fail();
|
|
9680
|
+
} else position = afterKey;
|
|
9681
|
+
if (Object.prototype.hasOwnProperty.call(props, key)) throw new Error(`Duplicate prop "${key}"`);
|
|
9682
|
+
Object.defineProperty(props, key, {
|
|
9683
|
+
value,
|
|
9684
|
+
enumerable: true,
|
|
9685
|
+
configurable: true,
|
|
9686
|
+
writable: true
|
|
9687
|
+
});
|
|
9688
|
+
}
|
|
9689
|
+
const selfClosing = source.slice(position, position + 2) === "/>";
|
|
9690
|
+
if (source[position] !== ">" && !selfClosing) fail();
|
|
9691
|
+
position += selfClosing ? 2 : 1;
|
|
9692
|
+
whitespace();
|
|
9693
|
+
if (position !== source.length) fail();
|
|
9694
|
+
return getElementData({
|
|
9695
|
+
name,
|
|
9696
|
+
props,
|
|
9697
|
+
selfClosing
|
|
9698
|
+
});
|
|
9699
|
+
};
|
|
9700
|
+
const canonicalElementValue = (value) => {
|
|
9701
|
+
if (Array.isArray(value)) return `[${value.map(canonicalElementValue).join(",")}]`;
|
|
9702
|
+
if (value && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonicalElementValue(item)}`).join(",")}}`;
|
|
9703
|
+
return JSON.stringify(value) ?? "null";
|
|
9704
|
+
};
|
|
9705
|
+
const formatElement = (data, width = 80) => {
|
|
9706
|
+
const closing = data.selfClosing ? " />" : ">";
|
|
9707
|
+
const formatProp = (key, value, multiline = false) => {
|
|
9708
|
+
const json = JSON.stringify(value, null, multiline ? 2 : void 0).replace(/"(?:\\[\s\S]|[^"\\])*"/g, (token, offset, source) => {
|
|
9709
|
+
const key = JSON.parse(token);
|
|
9710
|
+
return OBJECT_KEY.test(key) && /^\s*:/.test(source.slice(offset + token.length)) ? key : token;
|
|
9711
|
+
});
|
|
9712
|
+
if (value === true) return key;
|
|
9713
|
+
if (typeof value === "string" && !/[\n\r"\\]/.test(value)) return `${key}=${json}`;
|
|
9714
|
+
return `${key}={${json}}`;
|
|
9715
|
+
};
|
|
9716
|
+
const entries = Object.entries(data.props);
|
|
9717
|
+
const hasStructuredValues = entries.some(([, value]) => value !== null && typeof value === "object" && Object.keys(value).length > 0);
|
|
9718
|
+
const compact = `<${data.name}${entries.map(([key, value]) => ` ${formatProp(key, value)}`).join("")}${closing}`;
|
|
9719
|
+
if (!hasStructuredValues && compact.length <= width || !entries.length) return compact;
|
|
9720
|
+
const lines = entries.map(([key, value]) => {
|
|
9721
|
+
return formatProp(key, value, true).split("\n").map((line) => ` ${line}`).join("\n");
|
|
9722
|
+
});
|
|
9723
|
+
return `<${data.name}\n${lines.join("\n")}\n${closing.trimStart()}`;
|
|
9724
|
+
};
|
|
9725
|
+
const normalizeElementAttributes = (attrs) => {
|
|
9726
|
+
const data = getElementData(attrs);
|
|
9727
|
+
if (typeof attrs.source === "string") try {
|
|
9728
|
+
if (canonicalElementValue(parseElement(attrs.source)) === canonicalElementValue(data)) return {
|
|
9729
|
+
...attrs,
|
|
9730
|
+
...data
|
|
9731
|
+
};
|
|
9732
|
+
} catch {}
|
|
9733
|
+
return {
|
|
9734
|
+
...attrs,
|
|
9735
|
+
...data,
|
|
9736
|
+
source: formatElement(data)
|
|
9737
|
+
};
|
|
9738
|
+
};
|
|
9739
|
+
const findDisallowedElementBlock = (nodes, allowed, nested = false) => {
|
|
9740
|
+
for (const node of nodes) {
|
|
9741
|
+
if (!ELEMENT_BLOCKS.includes(node.type) && (!nested || !ELEMENT_STRUCTURE.includes(node.type))) return node.type;
|
|
9742
|
+
if (ELEMENT_BLOCKS.includes(node.type) && node.type !== "paragraph" && !allowed.includes(node.type)) return node.type;
|
|
9743
|
+
const invalid = findDisallowedElementBlock(node.content || [], allowed, true);
|
|
9744
|
+
if (invalid) return invalid;
|
|
9745
|
+
}
|
|
9746
|
+
return null;
|
|
9747
|
+
};
|
|
9748
|
+
//#endregion
|
|
9749
|
+
//#region ../../private/document/src/elements.ts
|
|
9750
|
+
const normalizeContentElements = (node) => {
|
|
9751
|
+
const content = node.content?.map(normalizeContentElements);
|
|
9752
|
+
const attrs = node.type === "element" ? normalizeElementAttributes(node.attrs || {}) : node.attrs;
|
|
9753
|
+
if (node.type === "codeBlock") {
|
|
9754
|
+
if (content?.some((child) => child.type !== "text" || typeof child.text !== "string" || !child.text || child.marks?.length || child.content?.length)) throw new Error("Code blocks can only contain plain text");
|
|
9755
|
+
if (attrs?.language != null && typeof attrs.language !== "string") throw new Error("Code block language must be a string");
|
|
9756
|
+
}
|
|
9757
|
+
if (node.type === "element") {
|
|
9758
|
+
const data = getElementData(attrs);
|
|
9759
|
+
const invalid = findDisallowedElementBlock(content || [], ELEMENT_BLOCKS);
|
|
9760
|
+
if (data.selfClosing && content?.length) throw new Error("Self-closing Elements cannot contain blocks");
|
|
9761
|
+
if (content?.some((child) => !ELEMENT_BLOCKS.includes(child.type)) || invalid) throw new Error("Elements can only contain regular editor blocks");
|
|
9762
|
+
}
|
|
9763
|
+
if (node.type === "fragment" && Array.isArray(attrs?.allowedBlocks)) {
|
|
9764
|
+
const invalid = findDisallowedElementBlock(content || [], attrs.allowedBlocks);
|
|
9765
|
+
if (invalid) throw new Error(`Fragment does not allow ${invalid} blocks`);
|
|
9766
|
+
}
|
|
9767
|
+
return {
|
|
9768
|
+
...node,
|
|
9769
|
+
...attrs ? { attrs } : {},
|
|
9770
|
+
...content ? { content } : {}
|
|
9771
|
+
};
|
|
9772
|
+
};
|
|
9773
|
+
//#endregion
|
|
9774
|
+
//#region ../../private/document/src/validation.ts
|
|
9775
|
+
const contentMarkType = z.object({
|
|
9776
|
+
type: z.string(),
|
|
9777
|
+
attrs: z.record(z.string(), z.unknown()).optional()
|
|
9778
|
+
});
|
|
9779
|
+
const contentNodeType = z.lazy(() => {
|
|
9780
|
+
return z.object({
|
|
9781
|
+
type: z.string(),
|
|
9782
|
+
attrs: z.record(z.string(), z.unknown()).optional(),
|
|
9783
|
+
content: z.array(contentNodeType).optional(),
|
|
9784
|
+
marks: z.array(contentMarkType).optional(),
|
|
9785
|
+
text: z.string().optional()
|
|
9786
|
+
}).superRefine((node, context) => {
|
|
9787
|
+
if (node.type !== "element" && node.type !== "fragment" && node.type !== "codeBlock") return;
|
|
9788
|
+
try {
|
|
9789
|
+
normalizeContentElements(node);
|
|
9790
|
+
} catch (error) {
|
|
9791
|
+
context.addIssue({
|
|
9792
|
+
code: "custom",
|
|
9793
|
+
message: error instanceof Error ? error.message : "Invalid Element content"
|
|
9794
|
+
});
|
|
9795
|
+
}
|
|
9796
|
+
});
|
|
9797
|
+
});
|
|
9798
|
+
//#endregion
|
|
9799
|
+
//#region ../../private/contracts/src/extensions/contributions.ts
|
|
9800
|
+
const extensionContributionLimits = {
|
|
9801
|
+
elementViews: 50,
|
|
9802
|
+
blockActions: 20,
|
|
9803
|
+
panelsPerSide: 2,
|
|
9804
|
+
settingsSections: 5
|
|
9805
|
+
};
|
|
9806
|
+
/** Stable across versions; settings and managed records are keyed by it. */
|
|
9807
|
+
const extensionStableIDType = z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/, "Use a letter, then letters, digits, - or _");
|
|
9808
|
+
const extensionEntryType = z.string().regex(/^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/, "Use the name of an exported view").describe("The export of the frontend bundle that renders this contribution");
|
|
9809
|
+
/**
|
|
9810
|
+
* An icon class of the UnoCSS icons preset, e.g. `i-lucide:rocket`, or `i-acme:logo?bg` for a
|
|
9811
|
+
* colored icon. Sets come from the extension build (default, installed, or custom); the build
|
|
9812
|
+
* checks that each icon exists.
|
|
9813
|
+
*/
|
|
9814
|
+
const extensionIconType = z.string().max(120).regex(/^i-[a-z\d]+(?:-[a-z\d]+)*:[a-z\d]+(?:-[a-z\d]+)*(?:\?(?:bg|mask))?$/, "Use an icon class, e.g. i-lucide:rocket");
|
|
9815
|
+
const labelType = z.string().min(1).max(60);
|
|
9816
|
+
const elementNameType = z.string().max(100).regex(ELEMENT_TAG_NAME, "Use an element name, e.g. Accordion");
|
|
9817
|
+
/**
|
|
9818
|
+
* A root element view. `descendants` render elements of those names inside the root's content
|
|
9819
|
+
* (nearest root first), before their own root registrations.
|
|
9820
|
+
*/
|
|
9821
|
+
const extensionElementViewType = z.strictObject({
|
|
9822
|
+
id: extensionStableIDType,
|
|
9823
|
+
name: labelType,
|
|
9824
|
+
element: elementNameType,
|
|
9825
|
+
entry: extensionEntryType,
|
|
9826
|
+
descendants: z.array(z.strictObject({
|
|
9827
|
+
element: elementNameType,
|
|
9828
|
+
entry: extensionEntryType
|
|
9829
|
+
})).max(20).refine((descendants) => uniqueItems(descendants.map(({ element }) => element.toLowerCase())), "Use one view per descendant element").default([])
|
|
9830
|
+
});
|
|
9831
|
+
const extensionBlockActionType = z.strictObject({
|
|
9832
|
+
id: extensionStableIDType,
|
|
9833
|
+
label: labelType,
|
|
9834
|
+
icon: extensionIconType.optional(),
|
|
9835
|
+
blocks: z.array(z.string().refine((block) => ELEMENT_BLOCKS.includes(block), "Unknown block type")).min(1).max(ELEMENT_BLOCKS.length).refine(uniqueItems, "Blocks must be unique"),
|
|
9836
|
+
entry: extensionEntryType
|
|
9837
|
+
});
|
|
9838
|
+
const panelType = {
|
|
9839
|
+
id: extensionStableIDType,
|
|
9840
|
+
name: labelType,
|
|
9841
|
+
icon: extensionIconType
|
|
9842
|
+
};
|
|
9843
|
+
const extensionPanelType = z.discriminatedUnion("side", [z.strictObject({
|
|
9844
|
+
...panelType,
|
|
9845
|
+
side: z.literal("left"),
|
|
9846
|
+
entry: extensionEntryType
|
|
9847
|
+
}), z.strictObject({
|
|
9848
|
+
...panelType,
|
|
9849
|
+
side: z.literal("right"),
|
|
9850
|
+
context: z.enum([
|
|
9851
|
+
"workspace",
|
|
9852
|
+
"collection",
|
|
9853
|
+
"entry"
|
|
9854
|
+
]),
|
|
9855
|
+
entry: extensionEntryType
|
|
9856
|
+
})]);
|
|
9857
|
+
const extensionSettingsSectionType = z.strictObject({
|
|
9858
|
+
id: extensionStableIDType,
|
|
9859
|
+
heading: labelType,
|
|
9860
|
+
entry: extensionEntryType
|
|
9861
|
+
});
|
|
9862
|
+
const extensionContributionsType = {
|
|
9863
|
+
elementViews: z.array(extensionElementViewType).max(extensionContributionLimits.elementViews).refine((views) => uniqueItems(views.map(({ element }) => element.toLowerCase())), "Use one view per element").default([]),
|
|
9864
|
+
blockActions: z.array(extensionBlockActionType).max(extensionContributionLimits.blockActions).default([]),
|
|
9865
|
+
panels: z.array(extensionPanelType).refine((panels) => {
|
|
9866
|
+
return ["left", "right"].every((side) => {
|
|
9867
|
+
return panels.filter((panel) => panel.side === side).length <= extensionContributionLimits.panelsPerSide;
|
|
9868
|
+
});
|
|
9869
|
+
}, `Use at most ${extensionContributionLimits.panelsPerSide} panels per side`).default([]),
|
|
9870
|
+
settingsSections: z.array(extensionSettingsSectionType).max(extensionContributionLimits.settingsSections).default([])
|
|
9871
|
+
};
|
|
9872
|
+
//#endregion
|
|
9873
|
+
//#region ../../private/contracts/src/api/schemas/webhooks.ts
|
|
9874
|
+
const webhookCollectionScopeType = z.discriminatedUnion("mode", [z.strictObject({ mode: z.literal("all") }), z.strictObject({
|
|
9875
|
+
mode: z.literal("selected"),
|
|
9876
|
+
roots: z.array(publicID("coll")).min(1).max(100).refine(uniqueItems, "Roots must be unique")
|
|
9877
|
+
})]);
|
|
9878
|
+
const webhookChannelScopeType = z.discriminatedUnion("mode", [z.strictObject({ mode: z.literal("all") }), z.strictObject({
|
|
9879
|
+
mode: z.literal("selected"),
|
|
9880
|
+
codes: z.array(webhookChannelCodeType).min(1).max(100).refine(uniqueItems, "Channels must be unique")
|
|
9881
|
+
})]);
|
|
9882
|
+
const webhookURLType = z.url({ protocol: /^https?$/ }).max(2048).refine((value) => {
|
|
9883
|
+
if (!URL.canParse(value)) return false;
|
|
9884
|
+
const url = new URL(value);
|
|
9885
|
+
return !url.username && !url.password && !value.includes("#");
|
|
9886
|
+
}, "Webhook URLs cannot include credentials or a fragment");
|
|
9887
|
+
const webhookConfigurationType = z.strictObject({
|
|
9888
|
+
name: z.string().trim().min(1).max(100),
|
|
9889
|
+
url: webhookURLType,
|
|
9890
|
+
enabled: z.boolean(),
|
|
9891
|
+
eventTypes: z.array(webhookEventNameType).min(1).max(webhookEventNameType.options.length).refine(uniqueItems, "Event types must be unique"),
|
|
9892
|
+
schemaVersion: z.literal(1),
|
|
9893
|
+
collections: webhookCollectionScopeType,
|
|
9894
|
+
channels: webhookChannelScopeType,
|
|
9895
|
+
restrictedContent: z.boolean().describe("Include restricted collections, including ones restricted later")
|
|
9896
|
+
});
|
|
9897
|
+
webhookConfigurationType.extend({ enabled: z.boolean().default(true) });
|
|
9898
|
+
const webhookRevisionInputType = z.strictObject({
|
|
9899
|
+
id: publicID("wh"),
|
|
9900
|
+
expectedRevision: z.number().int().positive()
|
|
9901
|
+
});
|
|
9902
|
+
z.strictObject({ webhooks: z.array(webhookRevisionInputType).min(1).max(10).refine((items) => uniqueItems(items.map(({ id }) => id)), "Webhooks must be unique") }).extend({ enabled: z.boolean() });
|
|
9903
|
+
webhookConfigurationType.partial().extend(webhookRevisionInputType.shape).refine((value) => Object.keys(value).some((key) => key !== "id" && key !== "expectedRevision"), "Supply at least one configuration change");
|
|
9904
|
+
const webhookFailureCategoryType = z.enum([
|
|
9905
|
+
"http_status",
|
|
9906
|
+
"timeout",
|
|
9907
|
+
"network",
|
|
9908
|
+
"destination_policy",
|
|
9909
|
+
"internal"
|
|
9910
|
+
]);
|
|
9911
|
+
const webhookEndpointType = webhookConfigurationType.extend({
|
|
9912
|
+
id: publicID("wh"),
|
|
9913
|
+
workspaceID: publicID("ws"),
|
|
9914
|
+
revision: z.number().int().positive(),
|
|
9915
|
+
destinationRevision: z.number().int().positive(),
|
|
9916
|
+
createdAt: z.iso.datetime(),
|
|
9917
|
+
updatedAt: z.iso.datetime(),
|
|
9918
|
+
disabledReason: z.enum(["manual", "failures"]).nullable(),
|
|
9919
|
+
health: z.strictObject({
|
|
9920
|
+
consecutiveFailures: z.number().int().nonnegative(),
|
|
9921
|
+
firstFailureAt: z.iso.datetime().nullable(),
|
|
9922
|
+
lastFailureAt: z.iso.datetime().nullable(),
|
|
9923
|
+
lastFailureCategory: webhookFailureCategoryType.nullable(),
|
|
9924
|
+
lastSuccessAt: z.iso.datetime().nullable(),
|
|
9925
|
+
pendingCount: z.number().int().nonnegative(),
|
|
9926
|
+
failedCount: z.number().int().nonnegative()
|
|
9927
|
+
}),
|
|
9928
|
+
signing: z.strictObject({
|
|
9929
|
+
rotatedAt: z.iso.datetime(),
|
|
9930
|
+
previousSecretExpiresAt: z.iso.datetime().nullable()
|
|
9931
|
+
})
|
|
9932
|
+
});
|
|
9933
|
+
const webhookSecretType = z.string().regex(/^whsec_[A-Za-z\d+/]{43}=$/).describe("One-time signing secret. Never returned by configuration or history reads.");
|
|
9934
|
+
const webhookSecretResultType = z.strictObject({
|
|
9935
|
+
endpoint: webhookEndpointType,
|
|
9936
|
+
secret: webhookSecretType
|
|
9937
|
+
});
|
|
9938
|
+
z.discriminatedUnion("secretChanged", [z.strictObject({
|
|
9939
|
+
secretChanged: z.literal(false),
|
|
9940
|
+
endpoint: webhookEndpointType
|
|
9941
|
+
}), webhookSecretResultType.extend({ secretChanged: z.literal(true) })]);
|
|
9942
|
+
//#endregion
|
|
9943
|
+
//#region ../../private/contracts/src/webhooks/outbound.ts
|
|
9944
|
+
/** Delivered only to the webhooks of the extension they describe; they need no permission. */
|
|
9945
|
+
const extensionLifecycleEventNames = [
|
|
9946
|
+
"extension.installed",
|
|
9947
|
+
"extension.enabled",
|
|
9948
|
+
"extension.disabled",
|
|
9949
|
+
"extension.updated",
|
|
9950
|
+
"extension.configured",
|
|
9951
|
+
"extension.uninstalled"
|
|
9952
|
+
];
|
|
9953
|
+
const versionType = z.string().min(1).max(64);
|
|
9954
|
+
const lifecycleEventType = envelopeType.extend({ subject: z.strictObject({
|
|
9955
|
+
kind: z.literal("extension"),
|
|
9956
|
+
id: publicID("ext")
|
|
9957
|
+
}) });
|
|
9958
|
+
const versionDataType = z.strictObject({ version: versionType });
|
|
9959
|
+
const extensionLifecycleEventSchemas = {
|
|
9960
|
+
"extension.installed": lifecycleEventType.extend({
|
|
9961
|
+
type: z.literal("extension.installed"),
|
|
9962
|
+
data: versionDataType
|
|
9963
|
+
}),
|
|
9964
|
+
"extension.enabled": lifecycleEventType.extend({
|
|
9965
|
+
type: z.literal("extension.enabled"),
|
|
9966
|
+
data: versionDataType
|
|
9967
|
+
}),
|
|
9968
|
+
"extension.disabled": lifecycleEventType.extend({
|
|
9969
|
+
type: z.literal("extension.disabled"),
|
|
9970
|
+
data: versionDataType.extend({ reason: z.enum([
|
|
9971
|
+
"manual",
|
|
9972
|
+
"approval_required",
|
|
9973
|
+
"configuration_required",
|
|
9974
|
+
"revoked"
|
|
9975
|
+
]) })
|
|
9976
|
+
}),
|
|
9977
|
+
"extension.updated": lifecycleEventType.extend({
|
|
9978
|
+
type: z.literal("extension.updated"),
|
|
9979
|
+
data: versionDataType.extend({ previousVersion: versionType })
|
|
9980
|
+
}),
|
|
9981
|
+
"extension.configured": lifecycleEventType.extend({
|
|
9982
|
+
type: z.literal("extension.configured"),
|
|
9983
|
+
data: versionDataType.extend({ changedFields: z.array(z.string().max(64)).max(100).describe("Keys only, never values") })
|
|
9984
|
+
}),
|
|
9985
|
+
"extension.uninstalled": lifecycleEventType.extend({
|
|
9986
|
+
type: z.literal("extension.uninstalled"),
|
|
9987
|
+
data: versionDataType
|
|
9988
|
+
})
|
|
9989
|
+
};
|
|
9990
|
+
const outboundEventNameType = z.enum([...webhookEventNames, ...extensionLifecycleEventNames]);
|
|
9991
|
+
z.discriminatedUnion("type", [
|
|
9992
|
+
...webhookEventType.options,
|
|
9993
|
+
extensionLifecycleEventSchemas["extension.installed"],
|
|
9994
|
+
extensionLifecycleEventSchemas["extension.enabled"],
|
|
9995
|
+
extensionLifecycleEventSchemas["extension.disabled"],
|
|
9996
|
+
extensionLifecycleEventSchemas["extension.updated"],
|
|
9997
|
+
extensionLifecycleEventSchemas["extension.configured"],
|
|
9998
|
+
extensionLifecycleEventSchemas["extension.uninstalled"]
|
|
9999
|
+
]);
|
|
10000
|
+
webhookConfigurationType.extend({ eventTypes: z.array(outboundEventNameType).min(1).max(outboundEventNameType.options.length).refine(uniqueItems, "Event types must be unique") });
|
|
10001
|
+
//#endregion
|
|
10002
|
+
//#region ../../private/contracts/src/extensions/webhooks.ts
|
|
10003
|
+
const extensionWebhookEventNameType = z.enum([...webhookEventNames, ...extensionLifecycleEventNames]);
|
|
10004
|
+
const supportsChannels = (event) => {
|
|
10005
|
+
if (event.startsWith("extension.")) return false;
|
|
10006
|
+
return webhookEventDefinitions[event].filters.some((filter) => {
|
|
10007
|
+
return filter === "channels";
|
|
10008
|
+
});
|
|
10009
|
+
};
|
|
10010
|
+
/** A path appended to the backend URL; segments cannot be empty, `.` or `..`. */
|
|
10011
|
+
const extensionWebhookPathType = z.string().max(200).regex(/^\/(?:[A-Za-z\d_~-][A-Za-z\d._~-]*\/?)*$/, "Use a path, e.g. /webhooks");
|
|
10012
|
+
const extensionWebhookType = z.strictObject({
|
|
10013
|
+
events: z.array(extensionWebhookEventNameType).min(1).max(extensionWebhookEventNameType.options.length).refine(uniqueItems, "Events must be unique"),
|
|
10014
|
+
path: extensionWebhookPathType,
|
|
10015
|
+
channels: z.array(webhookChannelCodeType).max(50).refine(uniqueItems, "Channels must be unique").describe("Publishing channels to match; empty for all channels").default([])
|
|
10016
|
+
}).refine(({ events, channels }) => !channels.length || events.some(supportsChannels), {
|
|
10017
|
+
message: "Channel filters need an event that supports them",
|
|
10018
|
+
path: ["channels"]
|
|
10019
|
+
});
|
|
10020
|
+
const MAX_EXTENSION_REQUEST_URLS = 10;
|
|
10021
|
+
const MAX_EXTENSION_BACKEND_KEYS = 3;
|
|
10022
|
+
/** Approving it requires workspace-wide restricted-content read authority. */
|
|
10023
|
+
const RESTRICTED_CONTENT_PERMISSION = "read:restricted_collections";
|
|
10024
|
+
const extensionPermissionType = z.enum([...keyPermissionType.options, RESTRICTED_CONTENT_PERMISSION]);
|
|
10025
|
+
const extensionNameType = z.string().regex(/^[a-z\d][a-z\d-]{0,38}\/[a-z\d][a-z\d-]{0,62}$/, "Use scope/name, e.g. acme/publish");
|
|
10026
|
+
const extensionVersionType = z.string().max(64).regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/, "Use a semantic version without build metadata, e.g. 1.4.0");
|
|
10027
|
+
const base64URLType = z.string().regex(/^[A-Za-z\d_-]{43}$/, "Use a base64url 32-byte value");
|
|
10028
|
+
const extensionBackendKeyIDType = z.string().regex(/^[A-Za-z\d._-]{1,64}$/, "Use a key ID");
|
|
10029
|
+
/** Public JWKs only; strict objects reject private key members. */
|
|
10030
|
+
const extensionBackendKeyType = z.discriminatedUnion("alg", [z.strictObject({
|
|
10031
|
+
kid: extensionBackendKeyIDType,
|
|
10032
|
+
kty: z.literal("OKP"),
|
|
10033
|
+
alg: z.literal("EdDSA"),
|
|
10034
|
+
crv: z.literal("Ed25519"),
|
|
10035
|
+
x: base64URLType
|
|
10036
|
+
}), z.strictObject({
|
|
10037
|
+
kid: extensionBackendKeyIDType,
|
|
10038
|
+
kty: z.literal("EC"),
|
|
10039
|
+
alg: z.literal("ES256"),
|
|
10040
|
+
crv: z.literal("P-256"),
|
|
10041
|
+
x: base64URLType,
|
|
10042
|
+
y: base64URLType
|
|
10043
|
+
})]);
|
|
10044
|
+
/** Canonical URL with an optional path prefix and no credentials, query, or fragment. */
|
|
10045
|
+
const createURLType = (protocols) => {
|
|
10046
|
+
return z.string().max(2048).refine((value) => {
|
|
10047
|
+
if (!URL.canParse(value)) return false;
|
|
10048
|
+
const url = new URL(value);
|
|
10049
|
+
const isCanonical = value === url.href || url.pathname === "/" && value === url.origin;
|
|
10050
|
+
const hasExtraParts = url.username || url.password || url.search || url.hash;
|
|
10051
|
+
return protocols.includes(url.protocol) && isCanonical && !hasExtraParts;
|
|
10052
|
+
}, `Use a canonical ${protocols.join(" or ")} URL, e.g. https://api.example.com/v1/`);
|
|
10053
|
+
};
|
|
10054
|
+
const createManifestType = (protocols) => {
|
|
10055
|
+
const urlType = createURLType(protocols);
|
|
10056
|
+
return z.strictObject({
|
|
10057
|
+
name: extensionNameType,
|
|
10058
|
+
version: extensionVersionType,
|
|
10059
|
+
displayName: z.string().min(1).max(60),
|
|
10060
|
+
description: z.string().min(1).max(300),
|
|
10061
|
+
/** The logo or main icon, shown in the catalog, extension list, and settings. */
|
|
10062
|
+
icon: extensionIconType.optional(),
|
|
10063
|
+
apiVersion: z.literal(1),
|
|
10064
|
+
permissions: z.array(extensionPermissionType).refine(uniqueItems, "Permissions must be unique").default([]),
|
|
10065
|
+
requests: z.array(urlType).max(MAX_EXTENSION_REQUEST_URLS).refine(uniqueItems, "Request URLs must be unique").describe("URLs that the frontend can request, besides the backend").default([]),
|
|
10066
|
+
backend: z.strictObject({
|
|
10067
|
+
url: urlType,
|
|
10068
|
+
keys: z.array(extensionBackendKeyType).min(1).max(MAX_EXTENSION_BACKEND_KEYS).refine((keys) => uniqueItems(keys.map(({ kid }) => kid)), "Key IDs must be unique")
|
|
10069
|
+
}).optional(),
|
|
10070
|
+
webhooks: z.record(extensionStableIDType, extensionWebhookType).refine((webhooks) => Object.keys(webhooks).length <= 10, `Use at most 10 webhooks`).default({}),
|
|
10071
|
+
configuration: extensionConfigurationType.optional(),
|
|
10072
|
+
...extensionContributionsType
|
|
10073
|
+
}).superRefine((manifest, context) => {
|
|
10074
|
+
const contributionIDs = [
|
|
10075
|
+
...manifest.elementViews,
|
|
10076
|
+
...manifest.blockActions,
|
|
10077
|
+
...manifest.panels,
|
|
10078
|
+
...manifest.settingsSections
|
|
10079
|
+
].map(({ id }) => id);
|
|
10080
|
+
const isGranted = (required) => {
|
|
10081
|
+
return manifest.permissions.some((permission) => hasPermission(permission, required));
|
|
10082
|
+
};
|
|
10083
|
+
const readsRestrictableContent = isGranted("read:entries") || isGranted("read:collections");
|
|
10084
|
+
const hasUnusedRestrictedGrant = manifest.permissions.includes("read:restricted_collections") && !readsRestrictableContent;
|
|
10085
|
+
if (!uniqueItems(contributionIDs)) context.addIssue({
|
|
10086
|
+
code: "custom",
|
|
10087
|
+
message: "Contribution IDs must be unique"
|
|
10088
|
+
});
|
|
10089
|
+
if (hasUnusedRestrictedGrant) context.addIssue({
|
|
10090
|
+
code: "custom",
|
|
10091
|
+
path: ["permissions"],
|
|
10092
|
+
message: "Restricted content needs read:entries or read:collections"
|
|
10093
|
+
});
|
|
10094
|
+
for (const [id, webhook] of Object.entries(manifest.webhooks)) {
|
|
10095
|
+
if (!manifest.backend) context.addIssue({
|
|
10096
|
+
code: "custom",
|
|
10097
|
+
path: ["webhooks", id],
|
|
10098
|
+
message: "Webhooks need a backend"
|
|
10099
|
+
});
|
|
10100
|
+
for (const [index, event] of webhook.events.entries()) {
|
|
10101
|
+
const missing = webhookEventDefinitions[event]?.requiredPermissions.filter((required) => !isGranted(required));
|
|
10102
|
+
if (missing?.length) context.addIssue({
|
|
10103
|
+
code: "custom",
|
|
10104
|
+
path: [
|
|
10105
|
+
"webhooks",
|
|
10106
|
+
id,
|
|
10107
|
+
"events",
|
|
10108
|
+
index
|
|
10109
|
+
],
|
|
10110
|
+
message: `The event needs ${missing.join(", ")}`
|
|
10111
|
+
});
|
|
10112
|
+
}
|
|
10113
|
+
}
|
|
10114
|
+
});
|
|
10115
|
+
};
|
|
10116
|
+
const extensionURLType = createURLType(["https:"]);
|
|
10117
|
+
const extensionManifestType = createManifestType(["https:"]);
|
|
10118
|
+
/** Development extensions run on a fully local stack, so their URLs can use HTTP. */
|
|
10119
|
+
const developmentExtensionManifestType = createManifestType(["https:", "http:"]);
|
|
10120
|
+
//#endregion
|
|
10121
|
+
//#region ../../private/contracts/src/extensions/registry.ts
|
|
10122
|
+
const splitVersion = (version) => {
|
|
10123
|
+
const separator = version.indexOf("-");
|
|
10124
|
+
const core = separator < 0 ? version : version.slice(0, separator);
|
|
10125
|
+
const prerelease = separator < 0 ? [] : version.slice(separator + 1).split(".");
|
|
10126
|
+
return [core.split(".").map(Number), prerelease];
|
|
10127
|
+
};
|
|
10128
|
+
const compareIdentifiers = (a, b) => {
|
|
10129
|
+
const isNumericA = /^\d+$/.test(a);
|
|
10130
|
+
const isNumericB = /^\d+$/.test(b);
|
|
10131
|
+
if (isNumericA && isNumericB) return Number(a) - Number(b);
|
|
10132
|
+
if (isNumericA !== isNumericB) return isNumericA ? -1 : 1;
|
|
10133
|
+
return a < b ? -1 : Number(a > b);
|
|
10134
|
+
};
|
|
10135
|
+
/** Semantic version precedence: negative when `a` is older, positive when it is newer. */
|
|
10136
|
+
const compareExtensionVersions = (a, b) => {
|
|
10137
|
+
const [coreA, prereleaseA] = splitVersion(a);
|
|
10138
|
+
const [coreB, prereleaseB] = splitVersion(b);
|
|
10139
|
+
const coreDifference = coreA.map((part, index) => part - coreB[index]).find(Boolean);
|
|
10140
|
+
if (coreDifference) return coreDifference;
|
|
10141
|
+
if (!prereleaseA.length || !prereleaseB.length) return prereleaseB.length - prereleaseA.length;
|
|
10142
|
+
for (let index = 0; index < Math.min(prereleaseA.length, prereleaseB.length); index += 1) {
|
|
10143
|
+
const difference = compareIdentifiers(prereleaseA[index], prereleaseB[index]);
|
|
10144
|
+
if (difference) return difference;
|
|
10145
|
+
}
|
|
10146
|
+
return prereleaseA.length - prereleaseB.length;
|
|
10147
|
+
};
|
|
10148
|
+
const MAX_EXTENSION_ARTIFACT_SIZE = 5242880;
|
|
10149
|
+
const MAX_REGISTRY_EXTENSIONS = 1e3;
|
|
10150
|
+
const MAX_REGISTRY_VERSIONS = 500;
|
|
10151
|
+
const keySetShape = {
|
|
10152
|
+
keys: z.array(extensionBackendKeyType).max(10),
|
|
10153
|
+
revokedKeys: z.array(extensionBackendKeyIDType).max(100).default([])
|
|
10154
|
+
};
|
|
10155
|
+
const refineKeySet = ({ keys, revokedKeys }, context) => {
|
|
10156
|
+
if (!uniqueItems([...keys.map(({ kid }) => kid), ...revokedKeys])) context.addIssue({
|
|
10157
|
+
code: "custom",
|
|
10158
|
+
path: ["keys"],
|
|
10159
|
+
message: "Key IDs must be unique and revoked keys cannot be listed as keys"
|
|
10160
|
+
});
|
|
10161
|
+
};
|
|
10162
|
+
/** The keys of a registry entry; extension projects keep them in `andesine.keys.json`. */
|
|
10163
|
+
const extensionKeySetType = z.object(keySetShape).superRefine(refineKeySet);
|
|
10164
|
+
const extensionArtifactType = z.strictObject({
|
|
10165
|
+
url: extensionURLType,
|
|
10166
|
+
sha256: z.string().regex(/^[a-f\d]{64}$/, "Use a lowercase hex SHA-256 digest"),
|
|
10167
|
+
size: z.int().positive().max(MAX_EXTENSION_ARTIFACT_SIZE)
|
|
10168
|
+
});
|
|
10169
|
+
/** An immutable registry version: the reviewed manifest and its build output. */
|
|
10170
|
+
const extensionVersionManifestType = z.strictObject({
|
|
10171
|
+
manifest: extensionManifestType,
|
|
10172
|
+
sourceCommit: z.string().regex(/^[a-f\d]{40}$/, "Use a full commit SHA"),
|
|
10173
|
+
artifacts: z.strictObject({
|
|
10174
|
+
frontend: extensionArtifactType,
|
|
10175
|
+
/** CSS for the extension views. */
|
|
10176
|
+
styles: extensionArtifactType.optional(),
|
|
10177
|
+
/** CSS for the manifest icons, which the host shows outside the extension views. */
|
|
10178
|
+
icons: extensionArtifactType.optional()
|
|
10179
|
+
}),
|
|
10180
|
+
publishedAt: z.iso.datetime()
|
|
10181
|
+
});
|
|
10182
|
+
const extensionRevocationType = z.strictObject({
|
|
10183
|
+
name: extensionNameType,
|
|
10184
|
+
version: extensionVersionType,
|
|
10185
|
+
reason: z.string().min(1).max(500),
|
|
10186
|
+
replacement: extensionVersionType.optional(),
|
|
10187
|
+
recordedAt: z.iso.datetime()
|
|
10188
|
+
});
|
|
10189
|
+
const extensionRegistryEntryType = z.object({
|
|
10190
|
+
name: extensionNameType,
|
|
10191
|
+
publisher: z.object({
|
|
10192
|
+
name: z.string().min(1).max(100),
|
|
10193
|
+
url: extensionURLType.optional()
|
|
10194
|
+
}),
|
|
10195
|
+
...keySetShape,
|
|
10196
|
+
versions: z.array(z.unknown()).max(MAX_REGISTRY_VERSIONS)
|
|
10197
|
+
}).superRefine(refineKeySet);
|
|
10198
|
+
const extensionRegistryIndexType = z.object({
|
|
10199
|
+
formatVersion: z.literal(1),
|
|
10200
|
+
generatedAt: z.iso.datetime(),
|
|
10201
|
+
extensions: z.array(extensionRegistryEntryType).max(MAX_REGISTRY_EXTENSIONS),
|
|
10202
|
+
revocations: z.array(extensionRevocationType).default([])
|
|
10203
|
+
}).refine(({ extensions }) => uniqueItems(extensions.map(({ name }) => name)), "Extension names must be unique");
|
|
10204
|
+
z.enum([
|
|
10205
|
+
"manual",
|
|
10206
|
+
"approval_required",
|
|
10207
|
+
"configuration_required",
|
|
10208
|
+
"revoked"
|
|
10209
|
+
]);
|
|
10210
|
+
//#endregion
|
|
10211
|
+
//#region src/extensions/frontend.ts
|
|
10212
|
+
const ENTRY_ID = "\0andesine-extension-entry";
|
|
10213
|
+
const require = createRequire(import.meta.url);
|
|
10214
|
+
/** The view entries that the manifest names, in manifest order. */
|
|
10215
|
+
const getEntries = (manifest) => {
|
|
10216
|
+
const entries = [
|
|
10217
|
+
...manifest.elementViews.flatMap((view) => [view.entry, ...view.descendants.map(({ entry }) => entry)]),
|
|
10218
|
+
...manifest.blockActions.map(({ entry }) => entry),
|
|
10219
|
+
...manifest.panels.map(({ entry }) => entry),
|
|
10220
|
+
...manifest.settingsSections.map(({ entry }) => entry)
|
|
10221
|
+
];
|
|
10222
|
+
return [...new Set(entries)];
|
|
10223
|
+
};
|
|
10224
|
+
/** Generates the `startExtension` call; a missing export fails the build. */
|
|
10225
|
+
const createEntryPlugin = (module, entries) => {
|
|
10226
|
+
const imports = entries.map((entry, index) => `${entry} as view${index}`).join(", ");
|
|
10227
|
+
const views = entries.map((entry, index) => `${entry}: view${index}`).join(", ");
|
|
10228
|
+
return {
|
|
10229
|
+
name: "andesine-extension-entry",
|
|
10230
|
+
resolveId: (id) => id === ENTRY_ID ? id : null,
|
|
10231
|
+
load: (id) => {
|
|
10232
|
+
if (id !== ENTRY_ID) return null;
|
|
10233
|
+
return [
|
|
10234
|
+
entries.length ? `import { ${imports} } from ${JSON.stringify(module)};` : "",
|
|
10235
|
+
`import { startExtension } from "@andesine/extensions/solid";`,
|
|
10236
|
+
`startExtension({ ${views} });`
|
|
10237
|
+
].join("\n");
|
|
10238
|
+
}
|
|
10239
|
+
};
|
|
10240
|
+
};
|
|
10241
|
+
/** Strips TypeScript, then compiles JSX for the extension renderer. */
|
|
10242
|
+
const solidPlugin = {
|
|
10243
|
+
name: "andesine-extension-solid",
|
|
10244
|
+
transform: {
|
|
10245
|
+
filter: { id: /\.[jt]sx$/ },
|
|
10246
|
+
handler: async (code, id) => {
|
|
10247
|
+
const stripped = transformSync(id, code, { jsx: "preserve" });
|
|
10248
|
+
if (stripped.errors.length) throw stripped.errors[0];
|
|
10249
|
+
return { code: (await transformAsync(stripped.code, {
|
|
10250
|
+
filename: id,
|
|
10251
|
+
babelrc: false,
|
|
10252
|
+
configFile: false,
|
|
10253
|
+
presets: [[require.resolve("babel-preset-solid"), {
|
|
10254
|
+
generate: "universal",
|
|
10255
|
+
moduleName: "@andesine/extensions/solid"
|
|
10256
|
+
}]]
|
|
10257
|
+
}))?.code ?? "" };
|
|
10258
|
+
}
|
|
10259
|
+
}
|
|
10260
|
+
};
|
|
10261
|
+
/** Bundles the frontend into one classic script for the extension worker. */
|
|
10262
|
+
const bundleFrontend = async (root, manifest, frontend, onWarning) => {
|
|
10263
|
+
const entries = getEntries(manifest);
|
|
10264
|
+
const module = path.resolve(root, frontend);
|
|
10265
|
+
const { output } = await build({
|
|
10266
|
+
cwd: root,
|
|
10267
|
+
input: ENTRY_ID,
|
|
10268
|
+
platform: "browser",
|
|
10269
|
+
plugins: [createEntryPlugin(module, entries), solidPlugin],
|
|
10270
|
+
transform: { define: { "process.env.NODE_ENV": JSON.stringify("production") } },
|
|
10271
|
+
write: false,
|
|
10272
|
+
onLog: (level, log, handler) => {
|
|
10273
|
+
if (log.code === "UNRESOLVED_IMPORT") handler("error", log);
|
|
10274
|
+
else if (level === "warn") onWarning(log.message);
|
|
10275
|
+
},
|
|
10276
|
+
output: {
|
|
10277
|
+
format: "iife",
|
|
10278
|
+
codeSplitting: false,
|
|
10279
|
+
minify: true
|
|
10280
|
+
}
|
|
10281
|
+
}).catch((error) => {
|
|
10282
|
+
throw new CLIError(`The frontend build failed:\n${(error.errors?.map(({ message }) => message) ?? [error.message]).map(stripVTControlCharacters).join("\n")}`, exitCodes.failure);
|
|
10283
|
+
});
|
|
10284
|
+
const [chunk] = output;
|
|
10285
|
+
const isProjectModule = (id) => {
|
|
10286
|
+
return id.startsWith(root + path.sep) && !id.includes(`${path.sep}node_modules${path.sep}`);
|
|
10287
|
+
};
|
|
10288
|
+
const sources = Object.entries(chunk.modules).filter(([id]) => isProjectModule(id)).map(([, rendered]) => rendered.code ?? "");
|
|
10289
|
+
return {
|
|
10290
|
+
code: chunk.code,
|
|
10291
|
+
sources: sources.join("\n")
|
|
10292
|
+
};
|
|
10293
|
+
};
|
|
10294
|
+
//#endregion
|
|
10295
|
+
//#region src/extensions/project.ts
|
|
10296
|
+
const CONFIG_FILE = "andesine.config.ts";
|
|
10297
|
+
const KEYS_FILE = "andesine.keys.json";
|
|
10298
|
+
/** Build output and private keys; the template ignores it in Git. */
|
|
10299
|
+
const WORK_DIRECTORY = ".andesine";
|
|
10300
|
+
const extensionBuildOptionsType = z.strictObject({
|
|
10301
|
+
frontend: z.string().min(1).optional(),
|
|
10302
|
+
icons: z.record(z.string().regex(/^[a-z\d]+(?:-[a-z\d]+)*$/), z.string().min(1)).optional()
|
|
10303
|
+
}).default({});
|
|
10304
|
+
const exists = async (file) => {
|
|
10305
|
+
return access(file).then(() => true, () => false);
|
|
10306
|
+
};
|
|
10307
|
+
const formatError = (subject, issues) => {
|
|
10308
|
+
return new CLIError(`${subject} is invalid:\n${issues.map(({ path: at, message }) => {
|
|
10309
|
+
return `- ${at.length ? `${at.map(String).join(".")}: ` : ""}${message}`;
|
|
10310
|
+
}).join("\n")}`, exitCodes.usage);
|
|
10311
|
+
};
|
|
10312
|
+
/** Bundles the config into the work directory, so its imports resolve from the project. */
|
|
10313
|
+
const importConfig = async (root) => {
|
|
10314
|
+
const file = path.join(root, WORK_DIRECTORY, "config.mjs");
|
|
10315
|
+
await build({
|
|
10316
|
+
cwd: root,
|
|
10317
|
+
input: path.join(root, CONFIG_FILE),
|
|
10318
|
+
platform: "node",
|
|
10319
|
+
external: (id) => !id.startsWith(".") && !path.isAbsolute(id),
|
|
10320
|
+
logLevel: "silent",
|
|
10321
|
+
output: {
|
|
10322
|
+
file,
|
|
10323
|
+
format: "esm"
|
|
10324
|
+
}
|
|
10325
|
+
});
|
|
10326
|
+
return import(`${pathToFileURL(file).href}?t=${Date.now()}`);
|
|
10327
|
+
};
|
|
10328
|
+
/**
|
|
10329
|
+
* Loads and validates the manifest and build options of the extension in `root`. In
|
|
10330
|
+
* development, the manifest's backend uses the development keys and URL, and may use local HTTP.
|
|
10331
|
+
*/
|
|
10332
|
+
const loadExtensionProject = async (root, development) => {
|
|
10333
|
+
if (!await exists(path.join(root, CONFIG_FILE))) throw new CLIError(`No ${CONFIG_FILE} in ${root}.`, exitCodes.usage);
|
|
10334
|
+
const config = await importConfig(root);
|
|
10335
|
+
const input = config.default;
|
|
10336
|
+
const developmentBackend = input?.backend && {
|
|
10337
|
+
...input.backend,
|
|
10338
|
+
keys: development?.keys,
|
|
10339
|
+
...development?.backendURL && { url: development.backendURL }
|
|
10340
|
+
};
|
|
10341
|
+
const manifest = development ? developmentExtensionManifestType.safeParse({
|
|
10342
|
+
...input,
|
|
10343
|
+
backend: developmentBackend
|
|
10344
|
+
}) : extensionManifestType.safeParse(input);
|
|
10345
|
+
const buildOptions = extensionBuildOptionsType.safeParse(config.build);
|
|
10346
|
+
if (!manifest.success) throw formatError("The manifest", manifest.error.issues);
|
|
10347
|
+
if (!buildOptions.success) throw formatError("The build options", buildOptions.error.issues);
|
|
10348
|
+
return {
|
|
10349
|
+
root,
|
|
10350
|
+
manifest: manifest.data,
|
|
10351
|
+
build: buildOptions.data
|
|
10352
|
+
};
|
|
10353
|
+
};
|
|
10354
|
+
const readExtensionKeys = async (root) => {
|
|
10355
|
+
const file = path.join(root, KEYS_FILE);
|
|
10356
|
+
if (!await exists(file)) return {
|
|
10357
|
+
keys: [],
|
|
10358
|
+
revokedKeys: []
|
|
10359
|
+
};
|
|
10360
|
+
const keys = extensionKeySetType.safeParse(JSON.parse(await readFile(file, "utf8")));
|
|
10361
|
+
if (!keys.success) throw formatError(KEYS_FILE, keys.error.issues);
|
|
10362
|
+
return keys.data;
|
|
10363
|
+
};
|
|
10364
|
+
const writeExtensionKeys = async (root, keys) => {
|
|
10365
|
+
await writeFile(path.join(root, KEYS_FILE), `${JSON.stringify(keys, null, 2)}\n`);
|
|
10366
|
+
};
|
|
10367
|
+
const ALLOWED_AT_RULES = /* @__PURE__ */ new Set(["media", "supports"]);
|
|
10368
|
+
const DATA_SVG_URL = /url\(\s*(?:"data:image\/svg\+xml[^"]*"|'data:image\/svg\+xml[^']*')\s*\)/gi;
|
|
10369
|
+
const FORBIDDEN_VALUE = /url\(|\bsrc\(|\bimage\(|image-set\(|expression\(|-moz-binding|behavior\s*:|\\|@|[{}<]/i;
|
|
10370
|
+
const NEWLINE = /[\n\r\f]/;
|
|
10371
|
+
var ExtensionStyleError = class extends Error {};
|
|
10372
|
+
/** Host-owned content slots inside element views; extension CSS stops at them. */
|
|
10373
|
+
const EXTENSION_SLOT_SELECTOR = "[data-content-slot]";
|
|
10374
|
+
/** Views render inside `data-extension`; manifest icons inside `data-extension-icon`. */
|
|
10375
|
+
const getExtensionScope = (name, kind = "view") => {
|
|
10376
|
+
return `[${kind === "view" ? "data-extension" : "data-extension-icon"}="${name}"]`;
|
|
10377
|
+
};
|
|
10378
|
+
/** The `@scope` prelude of view CSS: the view down to, but not into, its content slots. */
|
|
10379
|
+
const getViewScopePrelude = (name) => {
|
|
10380
|
+
return `(${getExtensionScope(name)}) to (${EXTENSION_SLOT_SELECTOR})`;
|
|
10381
|
+
};
|
|
10382
|
+
const fail = (message) => {
|
|
10383
|
+
throw new ExtensionStyleError(message);
|
|
10384
|
+
};
|
|
10385
|
+
/**
|
|
10386
|
+
* Removes comments outside strings. Escapes and strings are read like the browser reads them,
|
|
10387
|
+
* and a raw newline in a string fails, so the validated text cannot differ from what applies.
|
|
10388
|
+
*/
|
|
10389
|
+
const stripComments = (css) => {
|
|
10390
|
+
let result = "";
|
|
10391
|
+
let quote = "";
|
|
10392
|
+
for (let index = 0; index < css.length; index += 1) {
|
|
10393
|
+
const char = css[index];
|
|
10394
|
+
if (char === "\\") {
|
|
10395
|
+
result += css.slice(index, index + 2);
|
|
10396
|
+
index += 1;
|
|
10397
|
+
} else if (quote) {
|
|
10398
|
+
if (NEWLINE.test(char)) fail("Unterminated CSS string");
|
|
10399
|
+
if (char === quote) quote = "";
|
|
10400
|
+
result += char;
|
|
10401
|
+
} else if (char === "/" && css[index + 1] === "*") {
|
|
10402
|
+
const end = css.indexOf("*/", index + 2);
|
|
10403
|
+
if (end < 0) fail("Unterminated CSS comment");
|
|
10404
|
+
result += " ";
|
|
10405
|
+
index = end + 1;
|
|
10406
|
+
} else {
|
|
10407
|
+
if (char === "\"" || char === "'") quote = char;
|
|
10408
|
+
result += char;
|
|
10409
|
+
}
|
|
10410
|
+
}
|
|
10411
|
+
return quote ? fail("Unterminated CSS string") : result;
|
|
10412
|
+
};
|
|
10413
|
+
/** Finds the next `stops` character outside strings, escapes, parentheses, and brackets, or -1. */
|
|
10414
|
+
const scan = (css, start, stops) => {
|
|
10415
|
+
let depth = 0;
|
|
10416
|
+
let quote = "";
|
|
10417
|
+
for (let index = start; index < css.length; index += 1) {
|
|
10418
|
+
const char = css[index];
|
|
10419
|
+
if (char === "\\") index += 1;
|
|
10420
|
+
else if (quote) {
|
|
10421
|
+
if (char === quote) quote = "";
|
|
10422
|
+
} else if (char === "\"" || char === "'") quote = char;
|
|
10423
|
+
else if (char === "(" || char === "[") depth += 1;
|
|
10424
|
+
else if (char === ")" || char === "]") depth -= 1;
|
|
10425
|
+
else if (depth === 0 && stops.includes(char)) return index;
|
|
10426
|
+
}
|
|
10427
|
+
return -1;
|
|
10428
|
+
};
|
|
10429
|
+
const readUntil = (css, start, stops) => {
|
|
10430
|
+
const index = scan(css, start, stops);
|
|
10431
|
+
return index < 0 ? fail("Unterminated CSS block") : index;
|
|
10432
|
+
};
|
|
10433
|
+
/** Finds the `}` that closes the block opened at `open`, counting nested blocks. */
|
|
10434
|
+
const findBlockEnd = (css, open) => {
|
|
10435
|
+
let depth = 1;
|
|
10436
|
+
let index = open;
|
|
10437
|
+
while (depth > 0) {
|
|
10438
|
+
index = readUntil(css, index + 1, "{}");
|
|
10439
|
+
depth += css[index] === "{" ? 1 : -1;
|
|
10440
|
+
}
|
|
10441
|
+
return index;
|
|
10442
|
+
};
|
|
10443
|
+
const splitSelectors = (prelude) => {
|
|
10444
|
+
const selectors = [];
|
|
10445
|
+
let start = 0;
|
|
10446
|
+
for (let end = scan(prelude, 0, ","); end >= 0; end = scan(prelude, start, ",")) {
|
|
10447
|
+
selectors.push(prelude.slice(start, end).trim());
|
|
10448
|
+
start = end + 1;
|
|
10449
|
+
}
|
|
10450
|
+
selectors.push(prelude.slice(start).trim());
|
|
10451
|
+
return selectors;
|
|
10452
|
+
};
|
|
10453
|
+
/** Every selector starts with `prefix` and a descendant combinator, never the prefix alone. */
|
|
10454
|
+
const isScopedSelectorList = (selectors, prefix) => {
|
|
10455
|
+
return splitSelectors(selectors).every((selector) => {
|
|
10456
|
+
return selector.startsWith(`${prefix} `) && selector.trim().length > prefix.length;
|
|
10457
|
+
});
|
|
10458
|
+
};
|
|
10459
|
+
/** Values load nothing (only inline SVG icon URLs) and contain no escapes or nested blocks. */
|
|
10460
|
+
const isSafeCSSValue = (value) => {
|
|
10461
|
+
return !FORBIDDEN_VALUE.test(value.replace(DATA_SVG_URL, ""));
|
|
10462
|
+
};
|
|
10463
|
+
/** `prefix` is null at the top level of view CSS, where only the `@scope` rule is allowed. */
|
|
10464
|
+
const validateBlock = (css, start, end, prefix, scopePrelude) => {
|
|
10465
|
+
let index = start;
|
|
10466
|
+
while (index < end) {
|
|
10467
|
+
while (index < end && /\s/.test(css[index])) index += 1;
|
|
10468
|
+
if (index >= end) return;
|
|
10469
|
+
const open = readUntil(css, index, "{;");
|
|
10470
|
+
if (css[open] === ";") fail("CSS statements must be rules");
|
|
10471
|
+
const prelude = css.slice(index, open).trim();
|
|
10472
|
+
const close = findBlockEnd(css, open);
|
|
10473
|
+
if (scan(prelude, 0, "}") >= 0) fail("Unexpected CSS block end");
|
|
10474
|
+
if (close > end) fail("Unterminated CSS block");
|
|
10475
|
+
if (prelude.startsWith("@")) {
|
|
10476
|
+
const atRule = (/^@([\w-]+)/.exec(prelude)?.[1] ?? "").toLowerCase();
|
|
10477
|
+
const condition = prelude.slice(atRule.length + 1).trim();
|
|
10478
|
+
const isViewScope = atRule === "scope" && prefix === null && condition === scopePrelude;
|
|
10479
|
+
const isCondition = ALLOWED_AT_RULES.has(atRule) && prefix !== null;
|
|
10480
|
+
if (!isViewScope && !isCondition) fail(`CSS at-rule not allowed here: @${atRule}`);
|
|
10481
|
+
if (!isSafeCSSValue(condition)) fail("Unsafe CSS condition");
|
|
10482
|
+
validateBlock(css, open + 1, close, isViewScope ? ":scope" : prefix, scopePrelude);
|
|
10483
|
+
} else {
|
|
10484
|
+
if (prefix === null) fail("Extension view CSS must be inside its @scope rule");
|
|
10485
|
+
if (!isScopedSelectorList(prelude, prefix)) fail(`CSS selector outside the scope: ${prelude}`);
|
|
10486
|
+
if (!isSafeCSSValue(css.slice(open + 1, close))) fail("CSS values cannot load resources or use escapes");
|
|
10487
|
+
}
|
|
10488
|
+
index = close + 1;
|
|
10489
|
+
}
|
|
10490
|
+
};
|
|
10491
|
+
/**
|
|
10492
|
+
* View CSS is one `@scope (<view>) to (<content slot>)` rule, so it never reaches host-owned
|
|
10493
|
+
* content in slots; icon CSS rules start with the icon scope. Inside, only style rules
|
|
10494
|
+
* (optionally in `@media`/`@supports`) without escapes in values or URL-bearing values other
|
|
10495
|
+
* than inline SVG icons. The host also checks the browser-parsed rules before applying them.
|
|
10496
|
+
*/
|
|
10497
|
+
const validateExtensionCSS = (css, name, kind = "view") => {
|
|
10498
|
+
if (css.length > 262144) fail("Extension CSS is too large");
|
|
10499
|
+
const withoutComments = stripComments(css);
|
|
10500
|
+
const prefix = kind === "view" ? null : getExtensionScope(name, kind);
|
|
10501
|
+
validateBlock(withoutComments, 0, withoutComments.length, prefix, getViewScopePrelude(name));
|
|
10502
|
+
};
|
|
10503
|
+
//#endregion
|
|
10504
|
+
//#region ../../private/uno-preset/src/index.ts
|
|
10505
|
+
const andesineTheme = { colors: {
|
|
10506
|
+
primary: "#ff3617",
|
|
10507
|
+
secondary: "#f88f52",
|
|
10508
|
+
tertiary: "#fc6335",
|
|
10509
|
+
gray: {
|
|
10510
|
+
...colors,
|
|
10511
|
+
850: "#1b2534",
|
|
10512
|
+
950: "#0e1422"
|
|
10513
|
+
}
|
|
10514
|
+
} };
|
|
10515
|
+
const andesineRules = [[/^mask-edge-fading-(?:\[side=(t|b|r|l)\]|(t|b|r|l))-(\d+)$/, ([, bracketSide, shortSide, value]) => {
|
|
10516
|
+
const side = bracketSide || shortSide;
|
|
10517
|
+
const rem = Number(value) / 4;
|
|
10518
|
+
return {
|
|
10519
|
+
"mask-image": `linear-gradient(${{
|
|
10520
|
+
t: "to bottom",
|
|
10521
|
+
b: "to top",
|
|
10522
|
+
r: "to left",
|
|
10523
|
+
l: "to right"
|
|
10524
|
+
}[side]}, transparent 0%, black ${rem}rem, black 100%)`,
|
|
10525
|
+
"mask-repeat": "no-repeat"
|
|
10526
|
+
};
|
|
10527
|
+
}], [/^mask-edge-fading-(\d+)$/, ([, value]) => {
|
|
10528
|
+
const rem = Number(value) / 4;
|
|
10529
|
+
return {
|
|
10530
|
+
"mask-image": [
|
|
10531
|
+
`linear-gradient(to top, black 0%, black 100%)`,
|
|
10532
|
+
`linear-gradient(to top, transparent 0%, black 100%)`,
|
|
10533
|
+
`linear-gradient(to right, transparent 0%, black 100%)`,
|
|
10534
|
+
`linear-gradient(to bottom, transparent 0%, black 100%)`,
|
|
10535
|
+
`linear-gradient(to left, transparent 0%, black 100%)`
|
|
10536
|
+
].join(", "),
|
|
10537
|
+
"mask-position": `center, top, right, bottom, left`,
|
|
10538
|
+
"mask-size": `100% 100%, 100% ${rem}rem, ${rem}rem 100%, 100% ${rem}rem, ${rem}rem 100%`,
|
|
10539
|
+
"mask-repeat": "no-repeat, no-repeat, no-repeat, no-repeat, no-repeat",
|
|
10540
|
+
"mask-composite": "subtract, add, add, add"
|
|
10541
|
+
};
|
|
10542
|
+
}]];
|
|
10543
|
+
/** Andesine theme tokens and rules, shared by the web app, landing, and extension builds. */
|
|
10544
|
+
const presetAndesine = definePreset(() => ({
|
|
10545
|
+
name: "andesine",
|
|
10546
|
+
theme: andesineTheme,
|
|
10547
|
+
rules: andesineRules
|
|
10548
|
+
}));
|
|
10549
|
+
//#endregion
|
|
10550
|
+
//#region ../../private/uno-preset/src/extensions.ts
|
|
10551
|
+
/** Icon sets that the extension build provides without installation. */
|
|
10552
|
+
const DEFAULT_ICON_SETS = ["lucide", "tabler"];
|
|
10553
|
+
const createExtensionGenerator = (icons) => {
|
|
10554
|
+
return createGenerator({
|
|
10555
|
+
presets: [
|
|
10556
|
+
presetWind3({ preflight: false }),
|
|
10557
|
+
presetIcons({
|
|
10558
|
+
collections: icons,
|
|
10559
|
+
processor: (css) => {
|
|
10560
|
+
delete css.width;
|
|
10561
|
+
delete css.height;
|
|
10562
|
+
}
|
|
10563
|
+
}),
|
|
10564
|
+
presetAndesine()
|
|
10565
|
+
],
|
|
10566
|
+
blocklist: [/^animate-/]
|
|
10567
|
+
});
|
|
10568
|
+
};
|
|
10569
|
+
/**
|
|
10570
|
+
* Generates the CSS of the extension views at build time: utility and icon classes found in the
|
|
10571
|
+
* code, in one `@scope` rule from the views down to their content slots, without preflights
|
|
10572
|
+
* (the host has them). Throws if the result is invalid, for example when an arbitrary value
|
|
10573
|
+
* loads a URL.
|
|
10574
|
+
*/
|
|
10575
|
+
const generateExtensionCSS = async (options) => {
|
|
10576
|
+
const { css: rules } = await (await createExtensionGenerator(options.icons)).generate(options.code, {
|
|
10577
|
+
preflights: false,
|
|
10578
|
+
scope: ":scope"
|
|
10579
|
+
});
|
|
10580
|
+
const css = rules.trim() ? `@scope ${getViewScopePrelude(options.name)} {\n${rules}\n}\n` : "";
|
|
10581
|
+
validateExtensionCSS(css, options.name);
|
|
10582
|
+
return css;
|
|
10583
|
+
};
|
|
10584
|
+
/** Generates the CSS of the manifest icons, which the host shows outside the views. */
|
|
10585
|
+
const generateExtensionIconCSS = async (options) => {
|
|
10586
|
+
const { css, matched } = await (await createExtensionGenerator(options.icons)).generate(options.classes.join(" "), {
|
|
10587
|
+
preflights: false,
|
|
10588
|
+
scope: getExtensionScope(options.name, "icon")
|
|
10589
|
+
});
|
|
10590
|
+
const missing = options.classes.filter((icon) => !matched.has(icon));
|
|
10591
|
+
if (missing.length) throw new ExtensionStyleError(`Unknown icons: ${missing.join(", ")}`);
|
|
10592
|
+
validateExtensionCSS(css, options.name, "icon");
|
|
10593
|
+
return css;
|
|
10594
|
+
};
|
|
10595
|
+
//#endregion
|
|
10596
|
+
//#region src/extensions/styles.ts
|
|
10597
|
+
const ICON_SET_PACKAGE = "@iconify-json/";
|
|
10598
|
+
const readJSON$1 = async (file) => JSON.parse(await readFile(file, "utf8"));
|
|
10599
|
+
/** Sets of a package resolver, loaded when the CSS uses them. */
|
|
10600
|
+
const loadPackageSets = (sets, require) => {
|
|
10601
|
+
return Object.fromEntries(sets.map((set) => [set, () => readJSON$1(require.resolve(`${ICON_SET_PACKAGE}${set}/icons.json`))]));
|
|
10602
|
+
};
|
|
10603
|
+
/** Default sets from the CLI, sets installed in the project, then custom SVG directories. */
|
|
10604
|
+
const loadIconCollections = async (root, custom = {}) => {
|
|
10605
|
+
const manifestPath = path.join(root, "package.json");
|
|
10606
|
+
const manifest = await exists(manifestPath) ? await readJSON$1(manifestPath) : {};
|
|
10607
|
+
const installed = Object.keys({
|
|
10608
|
+
...manifest.dependencies,
|
|
10609
|
+
...manifest.devDependencies
|
|
10610
|
+
}).filter((name) => name.startsWith(ICON_SET_PACKAGE)).map((name) => name.slice(14));
|
|
10611
|
+
return {
|
|
10612
|
+
...loadPackageSets(DEFAULT_ICON_SETS, createRequire(import.meta.url)),
|
|
10613
|
+
...loadPackageSets(installed, createRequire(manifestPath)),
|
|
10614
|
+
...Object.fromEntries(Object.entries(custom).map(([set, directory]) => {
|
|
10615
|
+
return [set, FileSystemIconLoader(path.resolve(root, directory))];
|
|
10616
|
+
}))
|
|
10617
|
+
};
|
|
10618
|
+
};
|
|
10619
|
+
const getManifestIcons = (manifest) => {
|
|
10620
|
+
const icons = [
|
|
10621
|
+
manifest.icon,
|
|
10622
|
+
...manifest.panels.map(({ icon }) => icon),
|
|
10623
|
+
...manifest.blockActions.map(({ icon }) => icon)
|
|
10624
|
+
];
|
|
10625
|
+
return [...new Set(icons.filter((icon) => icon !== void 0))];
|
|
10626
|
+
};
|
|
10627
|
+
/** The view CSS from the project's code, and the CSS of the manifest icons. */
|
|
10628
|
+
const generateExtensionStyles = async (root, manifest, sources, custom) => {
|
|
10629
|
+
const icons = await loadIconCollections(root, custom);
|
|
10630
|
+
const classes = getManifestIcons(manifest);
|
|
10631
|
+
try {
|
|
10632
|
+
return {
|
|
10633
|
+
styles: await generateExtensionCSS({
|
|
10634
|
+
name: manifest.name,
|
|
10635
|
+
icons,
|
|
10636
|
+
code: sources
|
|
10637
|
+
}),
|
|
10638
|
+
icons: classes.length ? await generateExtensionIconCSS({
|
|
10639
|
+
name: manifest.name,
|
|
10640
|
+
icons,
|
|
10641
|
+
classes
|
|
10642
|
+
}) : ""
|
|
10643
|
+
};
|
|
10644
|
+
} catch (error) {
|
|
10645
|
+
if (error instanceof ExtensionStyleError) throw new CLIError(error.message, exitCodes.usage);
|
|
10646
|
+
throw error;
|
|
10647
|
+
}
|
|
10648
|
+
};
|
|
10649
|
+
//#endregion
|
|
10650
|
+
//#region src/extensions/build.ts
|
|
10651
|
+
const OUTPUT_DIRECTORY = "dist";
|
|
10652
|
+
const DEFAULT_FRONTEND = "src/frontend/index.tsx";
|
|
10653
|
+
const artifactFiles = {
|
|
10654
|
+
frontend: "frontend.js",
|
|
10655
|
+
styles: "styles.css",
|
|
10656
|
+
icons: "icons.css"
|
|
10657
|
+
};
|
|
10658
|
+
const describeArtifact = (file, content) => {
|
|
10659
|
+
const size = Buffer.byteLength(content);
|
|
10660
|
+
if (size > 5242880) throw new CLIError(`${file} is larger than ${MAX_EXTENSION_ARTIFACT_SIZE / 1024 / 1024} MB.`);
|
|
10661
|
+
return {
|
|
10662
|
+
file,
|
|
10663
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
10664
|
+
size
|
|
10665
|
+
};
|
|
10666
|
+
};
|
|
10667
|
+
/** Bundles the frontend and generates its CSS; empty CSS is an empty string. */
|
|
10668
|
+
const compileExtension = async (project, output) => {
|
|
10669
|
+
const { root, manifest } = project;
|
|
10670
|
+
const bundle = await output.progress("Bundling the frontend", () => {
|
|
10671
|
+
return bundleFrontend(root, manifest, project.build.frontend ?? DEFAULT_FRONTEND, (message) => {
|
|
10672
|
+
output.warning(message);
|
|
10673
|
+
});
|
|
10674
|
+
}, "Frontend bundled");
|
|
10675
|
+
const css = await output.progress("Generating the CSS", () => generateExtensionStyles(root, manifest, bundle.sources, project.build.icons), "CSS generated");
|
|
10676
|
+
return {
|
|
10677
|
+
frontend: bundle.code,
|
|
10678
|
+
styles: css.styles,
|
|
10679
|
+
icons: css.icons
|
|
10680
|
+
};
|
|
10681
|
+
};
|
|
10682
|
+
/** Validates the manifest, then writes the frontend, CSS, and metadata to `dist`. */
|
|
10683
|
+
const buildExtension = async (directory, output) => {
|
|
10684
|
+
const root = path.resolve(directory);
|
|
10685
|
+
const target = path.join(root, OUTPUT_DIRECTORY);
|
|
10686
|
+
const project = await output.progress("Loading the manifest", () => loadExtensionProject(root), "Manifest valid");
|
|
10687
|
+
const { manifest } = project;
|
|
10688
|
+
const contents = await compileExtension(project, output);
|
|
10689
|
+
const metadata = {
|
|
10690
|
+
manifest,
|
|
10691
|
+
artifacts: {}
|
|
10692
|
+
};
|
|
10693
|
+
await mkdir(target, { recursive: true });
|
|
10694
|
+
for (const [name, file] of Object.entries(artifactFiles)) {
|
|
10695
|
+
const content = contents[name];
|
|
10696
|
+
if (!content) {
|
|
10697
|
+
await rm(path.join(target, file), { force: true });
|
|
10698
|
+
continue;
|
|
10699
|
+
}
|
|
10700
|
+
metadata.artifacts[name] = describeArtifact(file, content);
|
|
10701
|
+
await writeFile(path.join(target, file), content);
|
|
10702
|
+
}
|
|
10703
|
+
await writeFile(path.join(target, "extension.json"), `${JSON.stringify(metadata, null, 2)}\n`);
|
|
10704
|
+
return metadata;
|
|
10705
|
+
};
|
|
10706
|
+
//#endregion
|
|
10707
|
+
//#region src/extensions/dev/client.ts
|
|
10708
|
+
/** A failure that the instance reported, as opposed to a failed build. */
|
|
10709
|
+
var InstanceError = class extends CLIError {};
|
|
10710
|
+
const LOCAL_HOSTS = [
|
|
10711
|
+
"localhost",
|
|
10712
|
+
"127.0.0.1",
|
|
10713
|
+
"[::1]"
|
|
10714
|
+
];
|
|
10715
|
+
const LOCAL_DOMAINS = [
|
|
10716
|
+
".localhost",
|
|
10717
|
+
".local",
|
|
10718
|
+
".test"
|
|
10719
|
+
];
|
|
10720
|
+
const isLocalInstance = (baseURL) => {
|
|
10721
|
+
const { hostname } = new URL(baseURL);
|
|
10722
|
+
return LOCAL_HOSTS.includes(hostname) || LOCAL_DOMAINS.some((domain) => hostname.endsWith(domain));
|
|
10723
|
+
};
|
|
10724
|
+
const describeFailure = (status, body) => {
|
|
10725
|
+
const message = body?.message;
|
|
10726
|
+
if (status === 404 && (body === null || message === "Not Found")) return "The instance has no development endpoints. Set PUBLIC_EXTENSIONS_ENABLED=true and EXTENSIONS_DEVELOPMENT_ENABLED=true on the local backend.";
|
|
10727
|
+
return typeof message === "string" ? message : `The request failed (HTTP ${status}).`;
|
|
10728
|
+
};
|
|
10729
|
+
/**
|
|
10730
|
+
* A client for the development operations of a local instance. They are not in the public API,
|
|
10731
|
+
* so the SDK has no methods for them; they need the signed-in member of a CLI profile.
|
|
10732
|
+
*/
|
|
10733
|
+
const createDevelopmentClient = (context) => {
|
|
10734
|
+
const { baseURL, workspaceID } = context.config;
|
|
10735
|
+
if (!isLocalInstance(baseURL)) throw new CLIError(`${baseURL} is not a local instance. Extension development needs a local stack (localhost, *.localhost, *.local, or *.test); use --base-url.`, exitCodes.usage);
|
|
10736
|
+
if (process.env.ANDESINE_API_KEY !== void 0) throw new CLIError("API keys cannot develop extensions. Unset ANDESINE_API_KEY and run andesine auth login.", exitCodes.usage);
|
|
10737
|
+
if (!workspaceID) throw new CLIError("Select a workspace with --workspace or ANDESINE_WORKSPACE_ID.", exitCodes.usage);
|
|
10738
|
+
const request = async (method, path, body, signal = context.signal) => {
|
|
10739
|
+
const response = await fetch(`${baseURL}/extensions${path}`, {
|
|
10740
|
+
method,
|
|
10741
|
+
redirect: "error",
|
|
10742
|
+
signal,
|
|
10743
|
+
headers: {
|
|
10744
|
+
"authorization": `Bearer ${await getAccessToken({
|
|
10745
|
+
...context,
|
|
10746
|
+
signal
|
|
10747
|
+
})}`,
|
|
10748
|
+
"x-workspace-id": workspaceID,
|
|
10749
|
+
...body === void 0 ? {} : { "content-type": "application/json" }
|
|
10750
|
+
},
|
|
10751
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
10752
|
+
});
|
|
10753
|
+
const result = await response.json().catch(() => null);
|
|
10754
|
+
if (!response.ok) throw new InstanceError(describeFailure(response.status, result));
|
|
10755
|
+
return result;
|
|
10756
|
+
};
|
|
10757
|
+
const webhookPath = (extensionID, webhookID) => {
|
|
10758
|
+
return `/${extensionID}/webhooks/${encodeURIComponent(webhookID)}`;
|
|
10759
|
+
};
|
|
10760
|
+
return {
|
|
10761
|
+
workspaceID,
|
|
10762
|
+
upload: (input) => request("PUT", "/development", input),
|
|
10763
|
+
stop: (extensionID, signal) => {
|
|
10764
|
+
return request("POST", `/development/${extensionID}/stop`, void 0, signal);
|
|
10765
|
+
},
|
|
10766
|
+
remove: (extensionID) => request("DELETE", `/development/${extensionID}`),
|
|
10767
|
+
sendTest: (extensionID, webhookID, type) => {
|
|
10768
|
+
return request("POST", `${webhookPath(extensionID, webhookID)}/test`, { type });
|
|
10769
|
+
},
|
|
10770
|
+
getDelivery: (extensionID, webhookID, deliveryID) => {
|
|
10771
|
+
return request("GET", `${webhookPath(extensionID, webhookID)}/deliveries/${deliveryID}`);
|
|
10772
|
+
}
|
|
10773
|
+
};
|
|
10774
|
+
};
|
|
10775
|
+
//#endregion
|
|
10776
|
+
//#region src/extensions/dev/state.ts
|
|
10777
|
+
const getStateFile = (root) => path.join(root, WORK_DIRECTORY, "development.json");
|
|
10778
|
+
const saveDevelopmentState = async (root, state) => {
|
|
10779
|
+
await writeFile(getStateFile(root), `${JSON.stringify(state, null, 2)}\n`);
|
|
10780
|
+
};
|
|
10781
|
+
/** The recorded development extension for the selected instance and workspace. */
|
|
10782
|
+
const readDevelopmentState = async (root, context) => {
|
|
10783
|
+
const file = getStateFile(root);
|
|
10784
|
+
const state = await exists(file) ? JSON.parse(await readFile(file, "utf8")) : null;
|
|
10785
|
+
if (!(state?.baseURL === context.config.baseURL && state.workspaceID === context.config.workspaceID)) throw new CLIError("No development extension for this instance and workspace. Run andesine extensions dev first.", exitCodes.usage);
|
|
10786
|
+
return state;
|
|
10787
|
+
};
|
|
10788
|
+
const clearDevelopmentState = async (root) => {
|
|
10789
|
+
await rm(getStateFile(root), { force: true });
|
|
10790
|
+
};
|
|
10791
|
+
//#endregion
|
|
10792
|
+
//#region src/extensions/dev/remove.ts
|
|
10793
|
+
/** Uninstalls the project's development extension, which deletes its development state. */
|
|
10794
|
+
const removeDevelopment = async (context, root) => {
|
|
10795
|
+
const directory = path.resolve(root);
|
|
10796
|
+
const client = createDevelopmentClient(context);
|
|
10797
|
+
const { extensionID } = await readDevelopmentState(directory, context);
|
|
10798
|
+
await context.output.progress("Removing the development extension", () => client.remove(extensionID), "Development extension removed");
|
|
10799
|
+
await clearDevelopmentState(directory);
|
|
10800
|
+
await context.output.success("The development extension and its state are removed.");
|
|
10801
|
+
};
|
|
10802
|
+
//#endregion
|
|
10803
|
+
//#region src/extensions/keys.ts
|
|
10804
|
+
const MAX_KEYS = 3;
|
|
10805
|
+
const KEY_ENVIRONMENT_VARIABLE = "ANDESINE_EXTENSION_KEY";
|
|
10806
|
+
const runFile$1 = promisify(execFile);
|
|
10807
|
+
const createKeyID = (taken) => {
|
|
10808
|
+
const base = `key-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
|
|
10809
|
+
let kid = base;
|
|
10810
|
+
for (let index = 2; taken.includes(kid); index += 1) kid = `${base}-${index}`;
|
|
10811
|
+
return kid;
|
|
10812
|
+
};
|
|
10813
|
+
const isIgnored$1 = async (root, file) => {
|
|
10814
|
+
try {
|
|
10815
|
+
await runFile$1("git", [
|
|
10816
|
+
"check-ignore",
|
|
10817
|
+
"--quiet",
|
|
10818
|
+
file
|
|
10819
|
+
], { cwd: root });
|
|
10820
|
+
return true;
|
|
10821
|
+
} catch (error) {
|
|
10822
|
+
return error.code !== 1;
|
|
10823
|
+
}
|
|
10824
|
+
};
|
|
10825
|
+
const parsePrivateKey = (source) => {
|
|
10826
|
+
try {
|
|
10827
|
+
const key = JSON.parse(source);
|
|
10828
|
+
if (key.d && key.kid) return key;
|
|
10829
|
+
} catch {}
|
|
10830
|
+
throw new CLIError("The private key must be a private JSON Web Key with a kid.", exitCodes.usage);
|
|
10831
|
+
};
|
|
10832
|
+
const toPublicKey = (privateKey) => {
|
|
10833
|
+
const jwk = createPublicKey(createPrivateKey({
|
|
10834
|
+
key: privateKey,
|
|
10835
|
+
format: "jwk"
|
|
10836
|
+
})).export({ format: "jwk" });
|
|
10837
|
+
const alg = jwk.crv === "Ed25519" ? "EdDSA" : "ES256";
|
|
10838
|
+
const key = extensionBackendKeyType.safeParse({
|
|
10839
|
+
kid: privateKey.kid,
|
|
10840
|
+
alg,
|
|
10841
|
+
...jwk
|
|
10842
|
+
});
|
|
10843
|
+
if (!key.success) throw new CLIError("Use an Ed25519 or P-256 private key.", exitCodes.usage);
|
|
10844
|
+
return key.data;
|
|
10845
|
+
};
|
|
10846
|
+
const describeKey$1 = (key) => {
|
|
10847
|
+
return [
|
|
10848
|
+
key.kid,
|
|
10849
|
+
key.alg,
|
|
10850
|
+
key.x,
|
|
10851
|
+
"y" in key ? key.y : ""
|
|
10852
|
+
].join(" ");
|
|
10853
|
+
};
|
|
10854
|
+
const isSameKey = (left, right) => {
|
|
10855
|
+
return describeKey$1(left) === describeKey$1(right);
|
|
10856
|
+
};
|
|
10857
|
+
/** Generates an Ed25519 key: the public JWK goes to the keys file, the private one stays local. */
|
|
10858
|
+
const generateExtensionKey = async (root, options = {}) => {
|
|
10859
|
+
const keys = await readExtensionKeys(root);
|
|
10860
|
+
const taken = [...keys.keys.map(({ kid }) => kid), ...keys.revokedKeys];
|
|
10861
|
+
const kid = options.kid ?? createKeyID(taken);
|
|
10862
|
+
const directory = path.join(root, WORK_DIRECTORY, "keys");
|
|
10863
|
+
const file = path.join(directory, `${kid}.json`);
|
|
10864
|
+
const isNewKeyID = extensionBackendKeyIDType.safeParse(kid).success && !taken.includes(kid);
|
|
10865
|
+
if (keys.keys.length >= MAX_KEYS) throw new CLIError(`${KEYS_FILE} has ${MAX_KEYS} keys. Revoke one first.`, exitCodes.usage);
|
|
10866
|
+
if (!isNewKeyID) throw new CLIError(`Use a new key ID of letters, digits, ".", "_" or "-".`, exitCodes.usage);
|
|
10867
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
10868
|
+
const privateJWK = {
|
|
10869
|
+
kid,
|
|
10870
|
+
alg: "EdDSA",
|
|
10871
|
+
...privateKey.export({ format: "jwk" })
|
|
10872
|
+
};
|
|
10873
|
+
const serialized = JSON.stringify(privateJWK);
|
|
10874
|
+
if (!options.print) {
|
|
10875
|
+
await mkdir(directory, {
|
|
10876
|
+
recursive: true,
|
|
10877
|
+
mode: 448
|
|
10878
|
+
});
|
|
10879
|
+
await writeFile(file, `${serialized}\n`, {
|
|
10880
|
+
mode: 384,
|
|
10881
|
+
flag: "wx"
|
|
10882
|
+
});
|
|
10883
|
+
}
|
|
10884
|
+
await writeExtensionKeys(root, {
|
|
10885
|
+
...keys,
|
|
10886
|
+
keys: [...keys.keys, toPublicKey(privateJWK)]
|
|
10887
|
+
});
|
|
10888
|
+
if (options.print) return {
|
|
10889
|
+
kid,
|
|
10890
|
+
privateKey: serialized,
|
|
10891
|
+
ignored: true
|
|
10892
|
+
};
|
|
10893
|
+
return {
|
|
10894
|
+
kid,
|
|
10895
|
+
privateKey: serialized,
|
|
10896
|
+
file,
|
|
10897
|
+
ignored: await isIgnored$1(root, file)
|
|
10898
|
+
};
|
|
10899
|
+
};
|
|
10900
|
+
/**
|
|
10901
|
+
* Checks that a private key matches a current key of the manifest and, with a registry URL, of
|
|
10902
|
+
* the registry entry, which Andesine instances trust.
|
|
10903
|
+
*/
|
|
10904
|
+
const checkExtensionKey = async (root, options = {}) => {
|
|
10905
|
+
const source = options.key ? await readFile(path.resolve(options.key), "utf8") : process.env[KEY_ENVIRONMENT_VARIABLE];
|
|
10906
|
+
if (!source) throw new CLIError(`Set ${KEY_ENVIRONMENT_VARIABLE} or use --key <file>.`, exitCodes.usage);
|
|
10907
|
+
const publicKey = toPublicKey(parsePrivateKey(source));
|
|
10908
|
+
const { manifest } = await loadExtensionProject(root);
|
|
10909
|
+
const { revokedKeys } = await readExtensionKeys(root);
|
|
10910
|
+
const manifestKey = manifest.backend?.keys.find(({ kid }) => kid === publicKey.kid);
|
|
10911
|
+
if (revokedKeys.includes(publicKey.kid)) throw new CLIError(`Key ${publicKey.kid} is revoked.`, exitCodes.failure);
|
|
10912
|
+
if (!manifestKey || !isSameKey(manifestKey, publicKey)) throw new CLIError(`Key ${publicKey.kid} is not a key of the manifest.`, exitCodes.failure);
|
|
10913
|
+
if (!options.registry) return {
|
|
10914
|
+
kid: publicKey.kid,
|
|
10915
|
+
name: manifest.name
|
|
10916
|
+
};
|
|
10917
|
+
const response = await fetch(options.registry);
|
|
10918
|
+
if (!response.ok) throw new CLIError(`The registry returned HTTP ${response.status}.`, exitCodes.failure);
|
|
10919
|
+
const index = extensionRegistryIndexType.safeParse(await response.json());
|
|
10920
|
+
if (!index.success) throw new CLIError("The registry index is invalid.", exitCodes.failure);
|
|
10921
|
+
const entry = index.data.extensions.find(({ name }) => name === manifest.name);
|
|
10922
|
+
const registryKey = entry?.keys.find(({ kid }) => kid === publicKey.kid);
|
|
10923
|
+
const isTrusted = Boolean(registryKey && isSameKey(registryKey, publicKey));
|
|
10924
|
+
if (!entry) throw new CLIError(`The registry has no ${manifest.name}.`, exitCodes.failure);
|
|
10925
|
+
if (!isTrusted) throw new CLIError(`The registry does not trust key ${publicKey.kid}.`, exitCodes.failure);
|
|
10926
|
+
return {
|
|
10927
|
+
kid: publicKey.kid,
|
|
10928
|
+
name: manifest.name,
|
|
10929
|
+
registry: options.registry
|
|
10930
|
+
};
|
|
10931
|
+
};
|
|
10932
|
+
/** Removes a public key from the manifest keys and records its ID as revoked. */
|
|
10933
|
+
const revokeExtensionKey = async (root, kid) => {
|
|
10934
|
+
const keys = await readExtensionKeys(root);
|
|
10935
|
+
if (!keys.keys.some((key) => key.kid === kid)) throw new CLIError(`${KEYS_FILE} has no key ${kid}.`, exitCodes.usage);
|
|
10936
|
+
await writeExtensionKeys(root, {
|
|
10937
|
+
keys: keys.keys.filter((key) => key.kid !== kid),
|
|
10938
|
+
revokedKeys: [...keys.revokedKeys, kid]
|
|
10939
|
+
});
|
|
10940
|
+
};
|
|
10941
|
+
//#endregion
|
|
10942
|
+
//#region src/extensions/dev/environment.ts
|
|
10943
|
+
const KEY_ID = "development";
|
|
10944
|
+
const INSTANCES_VARIABLE = "ANDESINE_INSTANCES";
|
|
10945
|
+
/**
|
|
10946
|
+
* The project's development key pair, created once. The local instance gets only the public
|
|
10947
|
+
* key; the private key stays in the work directory and the backend's environment file.
|
|
10948
|
+
*/
|
|
10949
|
+
const getDevelopmentKey = async (root) => {
|
|
10950
|
+
const directory = path.join(root, WORK_DIRECTORY, "keys");
|
|
10951
|
+
const file = path.join(directory, `${KEY_ID}.json`);
|
|
10952
|
+
if (!await exists(file)) {
|
|
10953
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
10954
|
+
const jwk = {
|
|
10955
|
+
kid: KEY_ID,
|
|
10956
|
+
alg: "EdDSA",
|
|
10957
|
+
...privateKey.export({ format: "jwk" })
|
|
10958
|
+
};
|
|
10959
|
+
await mkdir(directory, {
|
|
10960
|
+
recursive: true,
|
|
10961
|
+
mode: 448
|
|
10962
|
+
});
|
|
10963
|
+
await writeFile(file, `${JSON.stringify(jwk)}\n`, { mode: 384 });
|
|
10964
|
+
}
|
|
10965
|
+
const privateKey = (await readFile(file, "utf8")).trim();
|
|
10966
|
+
const { x } = JSON.parse(privateKey);
|
|
10967
|
+
return {
|
|
10968
|
+
privateKey,
|
|
10969
|
+
publicKey: {
|
|
10970
|
+
kid: KEY_ID,
|
|
10971
|
+
kty: "OKP",
|
|
10972
|
+
alg: "EdDSA",
|
|
10973
|
+
crv: "Ed25519",
|
|
10974
|
+
x
|
|
10975
|
+
}
|
|
10976
|
+
};
|
|
10977
|
+
};
|
|
10978
|
+
/** Sets a variable of an environment file, replacing its line or adding one. */
|
|
10979
|
+
const setVariable = (source, name, value) => {
|
|
10980
|
+
const line = `${name}=${value}`;
|
|
10981
|
+
const pattern = new RegExp(`^${name}=.*$`, "m");
|
|
10982
|
+
if (pattern.test(source)) return source.replace(pattern, () => line);
|
|
10983
|
+
return `${source}${source && !source.endsWith("\n") ? "\n" : ""}${line}\n`;
|
|
10984
|
+
};
|
|
10985
|
+
/**
|
|
10986
|
+
* Points the local backend at the development key and trusts the local instance, in its
|
|
10987
|
+
* environment file (created from `.env.example` when missing). Other variables stay.
|
|
10988
|
+
*/
|
|
10989
|
+
const writeDevelopmentEnvironment = async (root, file, key, baseURL) => {
|
|
10990
|
+
const target = path.resolve(root, file);
|
|
10991
|
+
const example = path.join(root, ".env.example");
|
|
10992
|
+
const sourceFile = await exists(target) ? target : example;
|
|
10993
|
+
const source = await exists(sourceFile) ? await readFile(sourceFile, "utf8") : "";
|
|
10994
|
+
const instances = new RegExp(`^${INSTANCES_VARIABLE}=(.*)$`, "m").exec(source)?.[1]?.split(",").filter(Boolean);
|
|
10995
|
+
const trusted = [.../* @__PURE__ */ new Set([...instances ?? [], baseURL])].join(",");
|
|
10996
|
+
const updated = setVariable(setVariable(source, KEY_ENVIRONMENT_VARIABLE, `'${key.privateKey}'`), INSTANCES_VARIABLE, trusted);
|
|
10997
|
+
await writeFile(target, updated, { mode: 384 });
|
|
10998
|
+
};
|
|
10999
|
+
//#endregion
|
|
11000
|
+
//#region src/extensions/dev/watch.ts
|
|
11001
|
+
const DEBOUNCE = 200;
|
|
11002
|
+
const IGNORED_DIRECTORIES = [
|
|
11003
|
+
"node_modules",
|
|
11004
|
+
"dist",
|
|
11005
|
+
WORK_DIRECTORY,
|
|
11006
|
+
".git"
|
|
11007
|
+
];
|
|
11008
|
+
const isIgnored = (file) => {
|
|
11009
|
+
const [directory] = file.split(path.sep);
|
|
11010
|
+
return IGNORED_DIRECTORIES.includes(directory) || path.basename(file).startsWith(".env");
|
|
11011
|
+
};
|
|
11012
|
+
/** Watches the project recursively until the signal aborts. */
|
|
11013
|
+
const watchChanges = (root, signal) => {
|
|
11014
|
+
let changed = false;
|
|
11015
|
+
let notify = null;
|
|
11016
|
+
let timer;
|
|
11017
|
+
watch(root, {
|
|
11018
|
+
recursive: true,
|
|
11019
|
+
signal
|
|
11020
|
+
}, (_event, file) => {
|
|
11021
|
+
if (!file || isIgnored(file)) return;
|
|
11022
|
+
clearTimeout(timer);
|
|
11023
|
+
timer = setTimeout(() => {
|
|
11024
|
+
changed = true;
|
|
11025
|
+
notify?.();
|
|
11026
|
+
}, DEBOUNCE);
|
|
11027
|
+
}).on("error", () => {});
|
|
11028
|
+
return { next: () => {
|
|
11029
|
+
return new Promise((resolve, reject) => {
|
|
11030
|
+
const abort = () => reject(signal.reason);
|
|
11031
|
+
notify = () => {
|
|
11032
|
+
notify = null;
|
|
11033
|
+
changed = false;
|
|
11034
|
+
signal.removeEventListener("abort", abort);
|
|
11035
|
+
resolve();
|
|
11036
|
+
};
|
|
11037
|
+
if (signal.aborted) abort();
|
|
11038
|
+
else if (changed) notify();
|
|
11039
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
11040
|
+
});
|
|
11041
|
+
} };
|
|
11042
|
+
};
|
|
11043
|
+
//#endregion
|
|
11044
|
+
//#region src/extensions/dev/run.ts
|
|
11045
|
+
const STOP_TIMEOUT = 1e4;
|
|
11046
|
+
const disabledHints = {
|
|
11047
|
+
approval_required: "approve its new permissions or URLs in Settings → Extensions",
|
|
11048
|
+
configuration_required: "fill in its required configuration in Settings → Extensions",
|
|
11049
|
+
manual: "enable it in Settings → Extensions"
|
|
11050
|
+
};
|
|
11051
|
+
const getErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
11052
|
+
/** Builds the project with the development key and uploads it, which installs or reloads it. */
|
|
11053
|
+
const uploadBuild = async (root, context, client, development) => {
|
|
11054
|
+
const project = await loadExtensionProject(root, development);
|
|
11055
|
+
const { frontend, styles, icons } = await compileExtension(project, context.output);
|
|
11056
|
+
const result = await context.output.progress("Uploading the build", () => {
|
|
11057
|
+
return client.upload({
|
|
11058
|
+
manifest: project.manifest,
|
|
11059
|
+
artifacts: {
|
|
11060
|
+
frontend,
|
|
11061
|
+
styles: styles || void 0,
|
|
11062
|
+
icons: icons || void 0
|
|
11063
|
+
}
|
|
11064
|
+
});
|
|
11065
|
+
}, "Build uploaded");
|
|
11066
|
+
const { name, version } = project.manifest;
|
|
11067
|
+
await saveDevelopmentState(root, {
|
|
11068
|
+
baseURL: context.config.baseURL,
|
|
11069
|
+
workspaceID: client.workspaceID,
|
|
11070
|
+
extensionID: result.id
|
|
11071
|
+
});
|
|
11072
|
+
if (result.state === "active") await context.output.success(`${name}@${version} is running (${result.id}).`);
|
|
11073
|
+
else await context.output.warning(`${name}@${version} is disabled: ${disabledHints[result.disabledReason ?? "manual"]}.`);
|
|
11074
|
+
return result;
|
|
11075
|
+
};
|
|
11076
|
+
/**
|
|
11077
|
+
* Runs an extension on the local instance for the signed-in member: uploads each build, reloads
|
|
11078
|
+
* it on file changes, and stops it on exit, keeping its configuration and storage.
|
|
11079
|
+
*/
|
|
11080
|
+
const runDevelopment = async (context, options) => {
|
|
11081
|
+
const { output, signal } = context;
|
|
11082
|
+
const root = path.resolve(options.root);
|
|
11083
|
+
const client = createDevelopmentClient(context);
|
|
11084
|
+
const key = await getDevelopmentKey(root);
|
|
11085
|
+
const development = {
|
|
11086
|
+
keys: [key.publicKey],
|
|
11087
|
+
backendURL: options.backend
|
|
11088
|
+
};
|
|
11089
|
+
let extensionID = null;
|
|
11090
|
+
await writeDevelopmentEnvironment(root, options.envFile, key, context.config.baseURL);
|
|
11091
|
+
await output.note("Local backend", {
|
|
11092
|
+
Environment: `${options.envFile} (development key and trusted instance)`,
|
|
11093
|
+
Instance: context.config.baseURL
|
|
11094
|
+
});
|
|
11095
|
+
try {
|
|
11096
|
+
extensionID = (await uploadBuild(root, context, client, development)).id;
|
|
11097
|
+
} catch (error) {
|
|
11098
|
+
if (signal.aborted || error instanceof InstanceError) throw error;
|
|
11099
|
+
await output.error(getErrorMessage(error));
|
|
11100
|
+
}
|
|
11101
|
+
const changes = watchChanges(root, signal);
|
|
11102
|
+
try {
|
|
11103
|
+
await output.diagnostic("Watching for changes. Press Ctrl+C to stop the extension.");
|
|
11104
|
+
while (true) {
|
|
11105
|
+
await changes.next();
|
|
11106
|
+
try {
|
|
11107
|
+
extensionID = (await uploadBuild(root, context, client, development)).id;
|
|
11108
|
+
} catch (error) {
|
|
11109
|
+
if (signal.aborted) throw error;
|
|
11110
|
+
await output.error(getErrorMessage(error));
|
|
11111
|
+
}
|
|
11112
|
+
}
|
|
11113
|
+
} finally {
|
|
11114
|
+
if (extensionID) await client.stop(extensionID, AbortSignal.timeout(STOP_TIMEOUT)).then(() => output.diagnostic("Development extension stopped; its state is kept.")).catch(() => output.warning("Could not stop the development extension."));
|
|
11115
|
+
}
|
|
11116
|
+
};
|
|
11117
|
+
//#endregion
|
|
11118
|
+
//#region src/extensions/dev/test-event.ts
|
|
11119
|
+
const POLL_INTERVAL = 1e3;
|
|
11120
|
+
const POLL_LIMIT = 30;
|
|
11121
|
+
const describeAttempt = ({ delivery }) => {
|
|
11122
|
+
const result = delivery.lastHTTPStatus ? `HTTP ${delivery.lastHTTPStatus}` : delivery.lastFailureCategory ?? "no response";
|
|
11123
|
+
return `${delivery.attemptCount} attempt(s), last: ${result}`;
|
|
11124
|
+
};
|
|
11125
|
+
/** Waits for the first attempt to finish, or the delivery to end. */
|
|
11126
|
+
const waitForResult = async (context, read) => {
|
|
11127
|
+
let details = await read();
|
|
11128
|
+
for (let poll = 0; poll < POLL_LIMIT; poll += 1) {
|
|
11129
|
+
const { state, attemptCount } = details.delivery;
|
|
11130
|
+
if (!(state === "in_flight" || state === "pending" && attemptCount === 0)) return details;
|
|
11131
|
+
await setTimeout$1(POLL_INTERVAL, void 0, { signal: context.signal });
|
|
11132
|
+
details = await read();
|
|
11133
|
+
}
|
|
11134
|
+
return details;
|
|
11135
|
+
};
|
|
11136
|
+
/** Sends a sample event to a webhook of the development extension and reports the result. */
|
|
11137
|
+
const sendTestEvent = async (context, options) => {
|
|
11138
|
+
const root = path.resolve(options.root);
|
|
11139
|
+
const client = createDevelopmentClient(context);
|
|
11140
|
+
const { extensionID } = await readDevelopmentState(root, context);
|
|
11141
|
+
const { deliveryID } = await client.sendTest(extensionID, options.webhookID, options.type);
|
|
11142
|
+
const details = await context.output.progress(`Sending ${options.type} to ${options.webhookID}`, () => {
|
|
11143
|
+
return waitForResult(context, () => {
|
|
11144
|
+
return client.getDelivery(extensionID, options.webhookID, deliveryID);
|
|
11145
|
+
});
|
|
11146
|
+
}, "Delivery attempted");
|
|
11147
|
+
const { state, nextAttemptAt } = details.delivery;
|
|
11148
|
+
const retry = nextAttemptAt ? `; next attempt at ${nextAttemptAt}` : "";
|
|
11149
|
+
if (state !== "succeeded") throw new CLIError(`Not delivered: ${state}, ${describeAttempt(details)}${retry}.`);
|
|
11150
|
+
await context.output.success(`Delivered (${describeAttempt(details)}).`);
|
|
11151
|
+
};
|
|
11152
|
+
//#endregion
|
|
11153
|
+
//#region src/extensions/registry/repository.ts
|
|
11154
|
+
const EXTENSIONS_DIRECTORY = "extensions";
|
|
11155
|
+
/** The registry entry data of a project that its manifest does not have. */
|
|
11156
|
+
const PUBLISHER_FILE = "andesine.registry.json";
|
|
11157
|
+
const REVOCATIONS_FILE = "registry/revocations.json";
|
|
11158
|
+
const INDEX_FILE = "index.json";
|
|
11159
|
+
const readJSON = async (file, label) => {
|
|
11160
|
+
try {
|
|
11161
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
11162
|
+
} catch {
|
|
11163
|
+
throw new CLIError(`${label} is not valid JSON.`, exitCodes.usage);
|
|
11164
|
+
}
|
|
11165
|
+
};
|
|
11166
|
+
const listDirectories = async (directory) => {
|
|
11167
|
+
if (!await exists(directory)) return [];
|
|
11168
|
+
return (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map(({ name }) => name).sort();
|
|
11169
|
+
};
|
|
11170
|
+
/** Loads every extension project with the registry rules for manifests (HTTPS URLs). */
|
|
11171
|
+
const readRegistryProjects = async (repository) => {
|
|
11172
|
+
const root = path.join(repository, EXTENSIONS_DIRECTORY);
|
|
11173
|
+
const projects = [];
|
|
11174
|
+
for (const scope of await listDirectories(root)) for (const name of await listDirectories(path.join(root, scope))) {
|
|
11175
|
+
const projectRoot = path.join(root, scope, name);
|
|
11176
|
+
const directoryName = `${scope}/${name}`;
|
|
11177
|
+
const publisherFile = path.join(projectRoot, PUBLISHER_FILE);
|
|
11178
|
+
if (!await exists(publisherFile)) throw new CLIError(`${directoryName} has no ${PUBLISHER_FILE}.`, exitCodes.usage);
|
|
11179
|
+
const data = await readJSON(publisherFile, `${directoryName}/${PUBLISHER_FILE}`);
|
|
11180
|
+
const publisher = extensionRegistryEntryType.shape.publisher.safeParse(data.publisher);
|
|
11181
|
+
if (!publisher.success) throw new CLIError(`${directoryName}/${PUBLISHER_FILE} has no valid publisher.`, exitCodes.usage);
|
|
11182
|
+
projects.push({
|
|
11183
|
+
directoryName,
|
|
11184
|
+
project: await loadExtensionProject(projectRoot),
|
|
11185
|
+
keys: await readExtensionKeys(projectRoot),
|
|
11186
|
+
publisher: publisher.data
|
|
11187
|
+
});
|
|
11188
|
+
}
|
|
11189
|
+
return projects;
|
|
11190
|
+
};
|
|
11191
|
+
const readRevocations = async (repository) => {
|
|
11192
|
+
const file = path.join(repository, REVOCATIONS_FILE);
|
|
11193
|
+
if (!await exists(file)) return [];
|
|
11194
|
+
const revocations = extensionRevocationType.array().safeParse(await readJSON(file, REVOCATIONS_FILE));
|
|
11195
|
+
if (!revocations.success) throw new CLIError(`${REVOCATIONS_FILE} is invalid.`, exitCodes.usage);
|
|
11196
|
+
return revocations.data;
|
|
11197
|
+
};
|
|
11198
|
+
/** The published index, if the site has one; every published version must be valid. */
|
|
11199
|
+
const readPublishedRegistry = async (directory) => {
|
|
11200
|
+
const file = path.join(directory, INDEX_FILE);
|
|
11201
|
+
const versions = /* @__PURE__ */ new Map();
|
|
11202
|
+
if (!await exists(file)) return {
|
|
11203
|
+
index: null,
|
|
11204
|
+
versions
|
|
11205
|
+
};
|
|
11206
|
+
const index = extensionRegistryIndexType.safeParse(await readJSON(file, INDEX_FILE));
|
|
11207
|
+
if (!index.success) throw new CLIError(`The published ${INDEX_FILE} is invalid.`);
|
|
11208
|
+
for (const entry of index.data.extensions) {
|
|
11209
|
+
const parsed = entry.versions.map((version) => extensionVersionManifestType.safeParse(version));
|
|
11210
|
+
if (parsed.some((result) => !result.success)) throw new CLIError(`A published version of ${entry.name} is invalid.`);
|
|
11211
|
+
versions.set(entry.name, parsed.map((result) => result.data));
|
|
11212
|
+
}
|
|
11213
|
+
return {
|
|
11214
|
+
index: index.data,
|
|
11215
|
+
versions
|
|
11216
|
+
};
|
|
11217
|
+
};
|
|
11218
|
+
//#endregion
|
|
11219
|
+
//#region src/extensions/registry/check.ts
|
|
11220
|
+
const hash = (content) => createHash("sha256").update(content).digest("hex");
|
|
11221
|
+
const describeKey = (key) => JSON.stringify(key, Object.keys(key).sort());
|
|
11222
|
+
const withoutKeys = (manifest) => {
|
|
11223
|
+
return JSON.stringify({
|
|
11224
|
+
...manifest,
|
|
11225
|
+
backend: manifest.backend && {
|
|
11226
|
+
...manifest.backend,
|
|
11227
|
+
keys: []
|
|
11228
|
+
}
|
|
11229
|
+
});
|
|
11230
|
+
};
|
|
11231
|
+
const findVersion = (versions, version) => {
|
|
11232
|
+
return versions.find(({ manifest }) => manifest.version === version);
|
|
11233
|
+
};
|
|
11234
|
+
/** Keys belong to the extension: published keys never change, revoked keys stay revoked. */
|
|
11235
|
+
const checkKeys = (entry, published) => {
|
|
11236
|
+
const { name, backend } = entry.project.manifest;
|
|
11237
|
+
const previous = published.index?.extensions.find((item) => item.name === name);
|
|
11238
|
+
const current = new Map(entry.keys.keys.map((key) => [key.kid, describeKey(key)]));
|
|
11239
|
+
const errors = [];
|
|
11240
|
+
for (const key of backend?.keys ?? []) if (current.get(key.kid) !== describeKey(key)) errors.push(`${name}: manifest key ${key.kid} is not a current key of the extension`);
|
|
11241
|
+
for (const key of previous?.keys ?? []) if (current.has(key.kid) && current.get(key.kid) !== describeKey(key)) errors.push(`${name}: published key ${key.kid} cannot change`);
|
|
11242
|
+
for (const kid of previous?.revokedKeys ?? []) if (!entry.keys.revokedKeys.includes(kid)) errors.push(`${name}: revoked key ${kid} must stay revoked`);
|
|
11243
|
+
return errors;
|
|
11244
|
+
};
|
|
11245
|
+
/** A version is new and newer than every published one, or identical to the published one. */
|
|
11246
|
+
const checkVersion = (entry, versions, contents) => {
|
|
11247
|
+
const { manifest } = entry.project;
|
|
11248
|
+
const published = findVersion(versions, manifest.version);
|
|
11249
|
+
if (!published) {
|
|
11250
|
+
const newer = versions.find(({ manifest: item }) => {
|
|
11251
|
+
return compareExtensionVersions(item.version, manifest.version) > 0;
|
|
11252
|
+
});
|
|
11253
|
+
return newer ? [`${manifest.name}@${manifest.version}: versions publish in order; ${newer.manifest.version} is published`] : [];
|
|
11254
|
+
}
|
|
11255
|
+
const errors = [];
|
|
11256
|
+
const styles = contents.styles ? hash(contents.styles) : void 0;
|
|
11257
|
+
if (withoutKeys(published.manifest) !== withoutKeys(manifest)) errors.push(`${manifest.name}@${manifest.version}: published versions cannot change; use a new version`);
|
|
11258
|
+
if (styles !== published.artifacts.styles?.sha256) errors.push(`${manifest.name}@${manifest.version}: the rebuilt styles differ from the published styles`);
|
|
11259
|
+
return errors;
|
|
11260
|
+
};
|
|
11261
|
+
/** Revocations are permanent and name published (or new) versions and replacements. */
|
|
11262
|
+
const checkRevocations = (revocations, published, available) => {
|
|
11263
|
+
const errors = [];
|
|
11264
|
+
const isRevoked = (name, version) => {
|
|
11265
|
+
return revocations.some((item) => item.name === name && item.version === version);
|
|
11266
|
+
};
|
|
11267
|
+
for (const previous of published.index?.revocations ?? []) if (!isRevoked(previous.name, previous.version)) errors.push(`${previous.name}@${previous.version}: revocations are permanent`);
|
|
11268
|
+
for (const revocation of revocations) {
|
|
11269
|
+
const versions = available.get(revocation.name) ?? [];
|
|
11270
|
+
const label = `${revocation.name}@${revocation.version}`;
|
|
11271
|
+
const isReplacementValid = !revocation.replacement || versions.includes(revocation.replacement) && !isRevoked(revocation.name, revocation.replacement);
|
|
11272
|
+
if (!versions.includes(revocation.version)) errors.push(`${label}: no such version`);
|
|
11273
|
+
if (!isReplacementValid) errors.push(`${label}: the replacement is not available`);
|
|
11274
|
+
}
|
|
11275
|
+
return errors;
|
|
11276
|
+
};
|
|
11277
|
+
/**
|
|
11278
|
+
* Checks the registry repository against the published site with the same rules that Andesine
|
|
11279
|
+
* instances apply on ingestion, and builds each project (its dependencies must be installed).
|
|
11280
|
+
*/
|
|
11281
|
+
const checkRegistry = async (repository, publishedDirectory, output) => {
|
|
11282
|
+
const projects = await readRegistryProjects(repository);
|
|
11283
|
+
const published = await readPublishedRegistry(publishedDirectory);
|
|
11284
|
+
const revocations = await readRevocations(repository);
|
|
11285
|
+
const available = /* @__PURE__ */ new Map();
|
|
11286
|
+
const newVersions = [];
|
|
11287
|
+
const errors = [];
|
|
11288
|
+
for (const entry of projects) {
|
|
11289
|
+
const { name, version } = entry.project.manifest;
|
|
11290
|
+
const versions = published.versions.get(name) ?? [];
|
|
11291
|
+
if (entry.directoryName !== name) errors.push(`${entry.directoryName}: the directory must match the name ${name}`);
|
|
11292
|
+
await output.diagnostic(`Building ${name}@${version}`);
|
|
11293
|
+
const contents = await compileExtension(entry.project, output);
|
|
11294
|
+
const versionErrors = checkVersion(entry, versions, contents);
|
|
11295
|
+
errors.push(...checkKeys(entry, published), ...versionErrors);
|
|
11296
|
+
available.set(name, [...versions.map(({ manifest }) => manifest.version), version]);
|
|
11297
|
+
if (!findVersion(versions, version) && !versionErrors.length) newVersions.push({
|
|
11298
|
+
entry,
|
|
11299
|
+
contents
|
|
11300
|
+
});
|
|
11301
|
+
}
|
|
11302
|
+
for (const name of published.versions.keys()) if (!projects.some((entry) => entry.project.manifest.name === name)) errors.push(`${name}: published extensions cannot be removed; revoke their versions`);
|
|
11303
|
+
errors.push(...checkRevocations(revocations, published, available));
|
|
11304
|
+
return {
|
|
11305
|
+
projects,
|
|
11306
|
+
published,
|
|
11307
|
+
revocations,
|
|
11308
|
+
newVersions,
|
|
11309
|
+
errors
|
|
11310
|
+
};
|
|
11311
|
+
};
|
|
11312
|
+
//#endregion
|
|
11313
|
+
//#region src/extensions/registry/build.ts
|
|
11314
|
+
const VERSION_FILE = "version.json";
|
|
11315
|
+
const runFile = promisify(execFile);
|
|
11316
|
+
const getCommit = async (repository, commit) => {
|
|
11317
|
+
const sha = commit ?? (await runFile("git", ["rev-parse", "HEAD"], { cwd: repository })).stdout.trim();
|
|
11318
|
+
if (!/^[a-f\d]{40}$/.test(sha)) throw new CLIError("Use a full commit SHA.", exitCodes.usage);
|
|
11319
|
+
return sha;
|
|
11320
|
+
};
|
|
11321
|
+
const getSiteURL = (siteURL) => {
|
|
11322
|
+
const url = URL.canParse(siteURL) ? new URL(siteURL) : null;
|
|
11323
|
+
if (url?.protocol !== "https:") throw new CLIError("Use an HTTPS base URL.", exitCodes.usage);
|
|
11324
|
+
return url.href.replace(/\/$/, "");
|
|
11325
|
+
};
|
|
11326
|
+
/** Writes a new version's files, which are never overwritten, and returns its manifest. */
|
|
11327
|
+
const writeVersion = async (published, siteURL, version, sourceCommit) => {
|
|
11328
|
+
const { manifest } = version.entry.project;
|
|
11329
|
+
const relative = path.posix.join(EXTENSIONS_DIRECTORY, manifest.name, manifest.version);
|
|
11330
|
+
const directory = path.join(published, relative);
|
|
11331
|
+
const artifacts = {};
|
|
11332
|
+
if (await exists(directory)) throw new CLIError(`${manifest.name}@${manifest.version} is already published.`);
|
|
11333
|
+
await mkdir(directory, { recursive: true });
|
|
11334
|
+
for (const [name, file] of Object.entries(artifactFiles)) {
|
|
11335
|
+
const content = version.contents[name];
|
|
11336
|
+
if (!content) continue;
|
|
11337
|
+
const { sha256, size } = describeArtifact(file, content);
|
|
11338
|
+
artifacts[name] = {
|
|
11339
|
+
url: `${siteURL}/${relative}/${file}`,
|
|
11340
|
+
sha256,
|
|
11341
|
+
size
|
|
11342
|
+
};
|
|
11343
|
+
await writeFile(path.join(directory, file), content);
|
|
11344
|
+
}
|
|
11345
|
+
const versionManifest = extensionVersionManifestType.parse({
|
|
11346
|
+
manifest,
|
|
11347
|
+
sourceCommit,
|
|
11348
|
+
artifacts,
|
|
11349
|
+
publishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
11350
|
+
});
|
|
11351
|
+
await writeFile(path.join(directory, VERSION_FILE), `${JSON.stringify(versionManifest, null, 2)}\n`);
|
|
11352
|
+
return versionManifest;
|
|
11353
|
+
};
|
|
11354
|
+
/**
|
|
11355
|
+
* Checks the registry, writes the files of new versions, then the index, which instances read;
|
|
11356
|
+
* the index comes last, so it never names files that are not published yet.
|
|
11357
|
+
*/
|
|
11358
|
+
const buildRegistry = async (options, output) => {
|
|
11359
|
+
const siteURL = getSiteURL(options.siteURL);
|
|
11360
|
+
const check = await checkRegistry(options.repository, options.published, output);
|
|
11361
|
+
const sourceCommit = await getCommit(options.repository, options.commit);
|
|
11362
|
+
const added = /* @__PURE__ */ new Map();
|
|
11363
|
+
if (check.errors.length) return {
|
|
11364
|
+
published: [],
|
|
11365
|
+
errors: check.errors
|
|
11366
|
+
};
|
|
11367
|
+
for (const version of check.newVersions) {
|
|
11368
|
+
const { name } = version.entry.project.manifest;
|
|
11369
|
+
added.set(name, await writeVersion(options.published, siteURL, version, sourceCommit));
|
|
11370
|
+
}
|
|
11371
|
+
const index = extensionRegistryIndexType.parse({
|
|
11372
|
+
formatVersion: 1,
|
|
11373
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11374
|
+
extensions: check.projects.map(({ project, keys, publisher }) => {
|
|
11375
|
+
const { name } = project.manifest;
|
|
11376
|
+
const versions = check.published.versions.get(name) ?? [];
|
|
11377
|
+
const version = added.get(name);
|
|
11378
|
+
return {
|
|
11379
|
+
name,
|
|
11380
|
+
publisher,
|
|
11381
|
+
keys: keys.keys,
|
|
11382
|
+
revokedKeys: keys.revokedKeys,
|
|
11383
|
+
versions: version ? [...versions, version] : versions
|
|
11384
|
+
};
|
|
11385
|
+
}),
|
|
11386
|
+
revocations: check.revocations
|
|
11387
|
+
});
|
|
11388
|
+
await writeFile(path.join(options.published, INDEX_FILE), `${JSON.stringify(index, null, 2)}\n`);
|
|
11389
|
+
return {
|
|
11390
|
+
published: [...added.values()].map(({ manifest }) => `${manifest.name}@${manifest.version}`),
|
|
11391
|
+
errors: []
|
|
11392
|
+
};
|
|
11393
|
+
};
|
|
11394
|
+
//#endregion
|
|
11395
|
+
//#region src/extensions/init.ts
|
|
11396
|
+
const TEMPLATE_NAME = "example/hello";
|
|
11397
|
+
const namedFiles = ["andesine.config.ts", "src/backend/server.ts"];
|
|
11398
|
+
const rename$1 = async (target, name) => {
|
|
11399
|
+
for (const file of namedFiles) {
|
|
11400
|
+
const source = await readFile(path.join(target, file), "utf8");
|
|
11401
|
+
await writeFile(path.join(target, file), source.replaceAll(`"${TEMPLATE_NAME}"`, `"${name}"`));
|
|
11402
|
+
}
|
|
11403
|
+
};
|
|
11404
|
+
/** `.env` for the local backend, with the private key; Git ignores it. */
|
|
11405
|
+
const writeEnvironment = async (target, key) => {
|
|
11406
|
+
const example = await readFile(path.join(target, ".env.example"), "utf8");
|
|
11407
|
+
await writeFile(path.join(target, ".env"), example.replace(`${KEY_ENVIRONMENT_VARIABLE}=`, `${KEY_ENVIRONMENT_VARIABLE}='${key}'`), { mode: 384 });
|
|
11408
|
+
};
|
|
11409
|
+
/** Creates an extension from the template, with a backend key pair. */
|
|
11410
|
+
const initExtension = async (directory, options, output) => {
|
|
11411
|
+
if (options.name && !extensionNameType.safeParse(options.name).success) throw new CLIError("Use scope/name for the extension name, e.g. acme/publish.", exitCodes.usage);
|
|
11412
|
+
const target = await copyTemplate("extension", directory, output);
|
|
11413
|
+
const key = await generateExtensionKey(target);
|
|
11414
|
+
if (options.name) await rename$1(target, options.name);
|
|
11415
|
+
await writeEnvironment(target, key.privateKey);
|
|
11416
|
+
await output.note("Next steps", {
|
|
11417
|
+
"Open the extension": `cd ${path.relative(process.cwd(), target) || "."}`,
|
|
11418
|
+
"Install": "npm install",
|
|
11419
|
+
"Build": "npm run build",
|
|
11420
|
+
"Start the backend": "npm run backend",
|
|
11421
|
+
"Backend key": `${key.kid} (private key in ${path.relative(target, key.file)} and .env)`
|
|
11422
|
+
});
|
|
11423
|
+
};
|
|
11424
|
+
//#endregion
|
|
11425
|
+
//#region src/extensions/register.ts
|
|
11426
|
+
const formatVersions = (manifests) => {
|
|
11427
|
+
return manifests.map(({ name, version }) => `${name}@${version}`).join(", ");
|
|
11428
|
+
};
|
|
11429
|
+
const reportRegistryErrors = async (output, errors) => {
|
|
11430
|
+
if (!errors.length) return;
|
|
11431
|
+
for (const error of errors) await output.error(error);
|
|
11432
|
+
throw new CLIError(`The registry has ${errors.length} problem(s).`);
|
|
11433
|
+
};
|
|
11434
|
+
const ROOT_DESCRIPTION = "Extension directory (default: current directory)";
|
|
11435
|
+
const registerExtensions = (program, signal) => {
|
|
11436
|
+
const getOutput = (command) => {
|
|
11437
|
+
return createOutput(command.optsWithGlobals().interactive, signal);
|
|
11438
|
+
};
|
|
11439
|
+
const extensions = program.command("extensions").description("Create and build extensions");
|
|
11440
|
+
const keys = extensions.command("keys").description("Manage the extension backend keys");
|
|
11441
|
+
extensions.command("init").description("Create an extension with a backend from the template in a new or empty directory").argument("[directory]", "Directory of the new extension", "extension").option("--name <scope/name>", "Registry name of the extension (default: example/hello)").action(async (directory, options, command) => {
|
|
11442
|
+
await initExtension(directory, options, getOutput(command));
|
|
11443
|
+
});
|
|
11444
|
+
extensions.command("build").description("Validate the manifest, then build the frontend, CSS, and dist/extension.json").option("--root <directory>", ROOT_DESCRIPTION, ".").action(async (options, command) => {
|
|
11445
|
+
const output = getOutput(command);
|
|
11446
|
+
const { manifest, artifacts } = await buildExtension(options.root, output);
|
|
11447
|
+
await output.note(`${manifest.name}@${manifest.version}`, {
|
|
11448
|
+
Frontend: `${artifacts.frontend?.size} bytes`,
|
|
11449
|
+
Styles: artifacts.styles && `${artifacts.styles.size} bytes`,
|
|
11450
|
+
Icons: artifacts.icons && `${artifacts.icons.size} bytes`
|
|
11451
|
+
});
|
|
11452
|
+
});
|
|
11453
|
+
extensions.command("dev").description("Run the extension on a local instance for you: upload each build, reload on changes, stop on exit").option("--root <directory>", ROOT_DESCRIPTION, ".").option("--env-file <file>", "The local backend's environment file", ".env").option("--backend <url>", "Local backend URL in place of the manifest's, e.g. http://localhost:3000").option("--remove", "Uninstall the development extension and delete its development state").addHelpText("after", "\nNeeds a local instance with EXTENSIONS_DEVELOPMENT_ENABLED=true, andesine auth login, and a\nworkspace. The backend's environment file gets the development key and the instance URL.").action(async (options, command) => {
|
|
11454
|
+
const context = await createCommandContext(command.optsWithGlobals(), signal);
|
|
11455
|
+
if (options.remove) await removeDevelopment(context, options.root);
|
|
11456
|
+
else await runDevelopment(context, options);
|
|
11457
|
+
});
|
|
11458
|
+
extensions.command("test-event").description("Send a sample event to a webhook of the development extension").argument("<webhook>", "Webhook ID from the manifest").argument("<type>", "Event type, e.g. entry.created").option("--root <directory>", ROOT_DESCRIPTION, ".").action(async (webhookID, type, options, command) => {
|
|
11459
|
+
const context = await createCommandContext(command.optsWithGlobals(), signal);
|
|
11460
|
+
await sendTestEvent(context, {
|
|
11461
|
+
root: options.root,
|
|
11462
|
+
webhookID,
|
|
11463
|
+
type
|
|
11464
|
+
});
|
|
11465
|
+
});
|
|
11466
|
+
const registry = extensions.command("registry").description("Check and build an extension registry repository (for its CI)");
|
|
11467
|
+
registry.command("check").description("Check every extension of the repository against the published registry").option("--repository <directory>", "Registry repository", ".").requiredOption("--published <directory>", "Checkout of the published registry site").action(async (options, command) => {
|
|
11468
|
+
const output = getOutput(command);
|
|
11469
|
+
const result = await checkRegistry(path.resolve(options.repository), path.resolve(options.published), output);
|
|
11470
|
+
await reportRegistryErrors(output, result.errors);
|
|
11471
|
+
await output.success(`The registry is valid; new versions: ${formatVersions(result.newVersions.map(({ entry }) => entry.project.manifest)) || "none"}.`);
|
|
11472
|
+
});
|
|
11473
|
+
registry.command("build").description("Write new versions and the index to the published registry site").option("--repository <directory>", "Registry repository", ".").requiredOption("--published <directory>", "Checkout of the published registry site").requiredOption("--site-url <url>", "HTTPS URL that serves the published site").option("--commit <sha>", "Repository commit of the build (default: HEAD)").action(async (options, command) => {
|
|
11474
|
+
const output = getOutput(command);
|
|
11475
|
+
const result = await buildRegistry({
|
|
11476
|
+
repository: path.resolve(options.repository),
|
|
11477
|
+
published: path.resolve(options.published),
|
|
11478
|
+
siteURL: options.siteUrl,
|
|
11479
|
+
commit: options.commit
|
|
11480
|
+
}, output);
|
|
11481
|
+
await reportRegistryErrors(output, result.errors);
|
|
11482
|
+
await output.success(`Published: ${result.published.join(", ") || "no new versions"}.`);
|
|
11483
|
+
});
|
|
11484
|
+
keys.command("generate").description(`Generate an Ed25519 key; add its public key to ${KEYS_FILE}`).option("--root <directory>", ROOT_DESCRIPTION, ".").option("--kid <id>", "Key ID (default: key-<date>)").option("--print", "Print the private JWK for a secret manager instead of saving it").action(async (options, command) => {
|
|
11485
|
+
const output = getOutput(command);
|
|
11486
|
+
const root = path.resolve(options.root);
|
|
11487
|
+
const key = await generateExtensionKey(root, options);
|
|
11488
|
+
if (!key.file) {
|
|
11489
|
+
await output.text(`${key.privateKey}\n`);
|
|
11490
|
+
return;
|
|
11491
|
+
}
|
|
11492
|
+
const file = path.relative(process.cwd(), key.file);
|
|
11493
|
+
if (!key.ignored) await output.warning(`Git does not ignore ${file}. Never commit it.`);
|
|
11494
|
+
await output.note(`Key ${key.kid}`, {
|
|
11495
|
+
"Private key": file,
|
|
11496
|
+
"Backend": `Set ${KEY_ENVIRONMENT_VARIABLE} to the file content`
|
|
11497
|
+
});
|
|
11498
|
+
});
|
|
11499
|
+
keys.command("check").description(`Check that the private key in ${KEY_ENVIRONMENT_VARIABLE} or --key matches a current key`).option("--root <directory>", ROOT_DESCRIPTION, ".").option("--key <file>", "Private JWK file").option("--registry <url>", "Also check the key in this registry index").action(async (options, command) => {
|
|
11500
|
+
const output = getOutput(command);
|
|
11501
|
+
const result = await checkExtensionKey(path.resolve(options.root), options);
|
|
11502
|
+
await output.success(`Key ${result.kid} is a current key of ${result.name}${result.registry ? " in the registry" : ""}.`);
|
|
11503
|
+
});
|
|
11504
|
+
keys.command("revoke").description(`Remove a key from ${KEYS_FILE} and list it as revoked`).argument("<kid>", "Key ID").option("--root <directory>", ROOT_DESCRIPTION, ".").action(async (kid, options, command) => {
|
|
11505
|
+
const output = getOutput(command);
|
|
11506
|
+
await revokeExtensionKey(path.resolve(options.root), kid);
|
|
11507
|
+
await output.success(`Key ${kid} is revoked. Publish a new version to stop using it.`);
|
|
11508
|
+
});
|
|
11509
|
+
};
|
|
9027
11510
|
//#endregion
|
|
9028
11511
|
//#region src/program.ts
|
|
9029
11512
|
/** Register custom commands and the generated public API group. */
|
|
@@ -9052,6 +11535,7 @@ const createProgram = (signal) => {
|
|
|
9052
11535
|
});
|
|
9053
11536
|
registerAPI(program, signal);
|
|
9054
11537
|
registerTypes(program, signal);
|
|
11538
|
+
registerExtensions(program, signal);
|
|
9055
11539
|
program.action(() => {
|
|
9056
11540
|
program.outputHelp();
|
|
9057
11541
|
});
|