andesine 0.4.0 → 0.5.0

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