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