@plank-cms/plank 0.27.3 → 0.28.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.
@@ -37,6 +37,8 @@ var DEFAULT_ROLE_PERMISSIONS = {
37
37
  "media:read",
38
38
  "media:write",
39
39
  "media:delete",
40
+ "addons:read",
41
+ "addons:write",
40
42
  "settings:overview:read",
41
43
  "settings:users:read",
42
44
  "settings:users:write",
@@ -650,8 +652,8 @@ function validate(contentType, payload) {
650
652
  import express from "express";
651
653
  import cors from "cors";
652
654
  import helmet from "helmet";
653
- import { join as join4, dirname as dirname2 } from "path";
654
- import { fileURLToPath as fileURLToPath3 } from "url";
655
+ import { join as join6, dirname as dirname3 } from "path";
656
+ import { fileURLToPath as fileURLToPath4 } from "url";
655
657
 
656
658
  // ../core/dist/routes/auth.js
657
659
  import { Router } from "express";
@@ -4875,6 +4877,10 @@ function maskSettings(namespace, settings) {
4875
4877
  }
4876
4878
  async function getNamespaceSettings(req, res) {
4877
4879
  const { namespace } = req.params;
4880
+ if (namespace.startsWith("addon:")) {
4881
+ res.status(403).json({ error: "Addon settings must be accessed through the add-ons API" });
4882
+ return;
4883
+ }
4878
4884
  const settings = await getSettings(namespace);
4879
4885
  res.json(maskSettings(namespace, settings));
4880
4886
  }
@@ -4903,6 +4909,10 @@ async function getEditorialMode(_req, res) {
4903
4909
  }
4904
4910
  async function updateNamespaceSettings(req, res) {
4905
4911
  const { namespace } = req.params;
4912
+ if (namespace.startsWith("addon:")) {
4913
+ res.status(403).json({ error: "Addon settings must be accessed through the add-ons API" });
4914
+ return;
4915
+ }
4906
4916
  const incoming = req.body;
4907
4917
  if (typeof incoming !== "object" || Array.isArray(incoming)) {
4908
4918
  res.status(400).json({ error: "Body must be a flat key-value object" });
@@ -4929,13 +4939,16 @@ async function updateNamespaceSettings(req, res) {
4929
4939
 
4930
4940
  // ../core/dist/lib/version.js
4931
4941
  import { readFile as readFile2 } from "fs/promises";
4942
+ import { join as join4 } from "path";
4932
4943
  import { fileURLToPath as fileURLToPath2 } from "url";
4933
4944
  var PACKAGE_NAME = "@plank-cms/plank";
4934
4945
  var CHANGELOG_BASE_URL = "https://github.com/plank-cms/plank/releases";
4935
- var UPDATE_COMMAND = "npm run update";
4936
4946
  var REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
4937
4947
  var CACHE_TTL_MS = 1e3 * 60 * 30;
4938
- var packageJsonUrl = new URL("../../package.json", import.meta.url);
4948
+ var packageJsonUrls = [
4949
+ new URL("../../package.json", import.meta.url),
4950
+ new URL("../package.json", import.meta.url)
4951
+ ];
4939
4952
  var cachedVersionCheck = null;
4940
4953
  function normalizeVersion(value) {
4941
4954
  return value.trim().replace(/^v/i, "").split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
@@ -4957,17 +4970,62 @@ function compareVersions(a2, b3) {
4957
4970
  function getChangelogUrl(version) {
4958
4971
  return version ? `${CHANGELOG_BASE_URL}/tag/${version}` : CHANGELOG_BASE_URL;
4959
4972
  }
4960
- async function readCurrentVersion() {
4961
- const packageJsonPath = fileURLToPath2(packageJsonUrl);
4962
- const raw = await readFile2(packageJsonPath, "utf8");
4963
- const parsed = JSON.parse(raw);
4964
- return parsed.version ?? "0.0.0";
4973
+ function getUpdateCommandForPackageManager(packageManager) {
4974
+ return packageManager === "pnpm" ? "pnpm run update" : "npm run update";
4975
+ }
4976
+ async function detectProjectPackageManager() {
4977
+ try {
4978
+ const raw = await readFile2(join4(process.cwd(), "package.json"), "utf8");
4979
+ const parsed = JSON.parse(raw);
4980
+ if (parsed.packageManager?.startsWith("pnpm@") || parsed.packageManager === "pnpm") {
4981
+ return "pnpm";
4982
+ }
4983
+ if (parsed.packageManager?.startsWith("npm@") || parsed.packageManager === "npm") {
4984
+ return "npm";
4985
+ }
4986
+ } catch {
4987
+ return await detectPackageManagerFromLockfiles();
4988
+ }
4989
+ return await detectPackageManagerFromLockfiles();
4990
+ }
4991
+ async function hasLockfile(filename) {
4992
+ try {
4993
+ await readFile2(join4(process.cwd(), filename), "utf8");
4994
+ return true;
4995
+ } catch {
4996
+ return false;
4997
+ }
4998
+ }
4999
+ async function detectPackageManagerFromLockfiles() {
5000
+ if (await hasLockfile("pnpm-lock.yaml")) {
5001
+ return "pnpm";
5002
+ }
5003
+ if (await hasLockfile("package-lock.json")) {
5004
+ return "npm";
5005
+ }
5006
+ return null;
5007
+ }
5008
+ async function getCurrentVersion() {
5009
+ for (const packageJsonUrl of packageJsonUrls) {
5010
+ try {
5011
+ const packageJsonPath = fileURLToPath2(packageJsonUrl);
5012
+ const raw = await readFile2(packageJsonPath, "utf8");
5013
+ const parsed = JSON.parse(raw);
5014
+ if (parsed.version) {
5015
+ return parsed.version;
5016
+ }
5017
+ } catch {
5018
+ continue;
5019
+ }
5020
+ }
5021
+ return "0.0.0";
4965
5022
  }
4966
5023
  async function getVersionCheck() {
4967
5024
  if (cachedVersionCheck && cachedVersionCheck.expiresAt > Date.now()) {
4968
5025
  return cachedVersionCheck.value;
4969
5026
  }
4970
- const currentVersion = await readCurrentVersion();
5027
+ const currentVersion = await getCurrentVersion();
5028
+ const packageManager = await detectProjectPackageManager();
4971
5029
  let latestVersion = null;
4972
5030
  try {
4973
5031
  const response = await fetch(REGISTRY_URL, {
@@ -4981,13 +5039,14 @@ async function getVersionCheck() {
4981
5039
  latestVersion = payload.version ?? null;
4982
5040
  }
4983
5041
  } catch {
5042
+ latestVersion = null;
4984
5043
  }
4985
5044
  const value = {
4986
5045
  currentVersion,
4987
5046
  latestVersion,
4988
5047
  updateAvailable: latestVersion ? compareVersions(latestVersion, currentVersion) > 0 : false,
4989
5048
  changelogUrl: getChangelogUrl(latestVersion),
4990
- updateCommand: UPDATE_COMMAND,
5049
+ updateCommand: getUpdateCommandForPackageManager(packageManager),
4991
5050
  checkedAt: (/* @__PURE__ */ new Date()).toISOString()
4992
5051
  };
4993
5052
  cachedVersionCheck = {
@@ -5003,6 +5062,657 @@ async function getVersionInfo(_req, res) {
5003
5062
  res.json(versionInfo);
5004
5063
  }
5005
5064
 
5065
+ // ../core/dist/controllers/addons.js
5066
+ import { z as z9 } from "zod";
5067
+
5068
+ // ../core/dist/lib/addons.js
5069
+ import { access, readFile as readFile3 } from "fs/promises";
5070
+ import { createRequire } from "module";
5071
+ import { dirname as dirname2, join as join5, resolve } from "path";
5072
+ import { fileURLToPath as fileURLToPath3 } from "url";
5073
+ import { z as z8 } from "zod";
5074
+ var ADDON_PACKAGE_PREFIX = "@plank-cms/addon-";
5075
+ function getHostRequire() {
5076
+ return createRequire(join5(process.cwd(), "package.json"));
5077
+ }
5078
+ var addonSlotSchema = z8.object({
5079
+ id: z8.string().min(1),
5080
+ title: z8.string().min(1),
5081
+ order: z8.number().int().optional()
5082
+ });
5083
+ var addonManifestSchema = z8.object({
5084
+ id: z8.string().min(1),
5085
+ packageName: z8.string().min(1),
5086
+ name: z8.string().min(1),
5087
+ version: z8.string().min(1),
5088
+ plankRange: z8.string().min(1),
5089
+ description: z8.string().optional(),
5090
+ settingsNamespace: z8.string().min(1).optional(),
5091
+ slots: z8.object({
5092
+ dashboardWidgets: z8.array(addonSlotSchema).optional(),
5093
+ addonsSections: z8.array(addonSlotSchema).optional()
5094
+ }),
5095
+ admin: z8.object({
5096
+ entry: z8.string().min(1)
5097
+ }).optional()
5098
+ });
5099
+ var addonAdminFieldSchema = z8.discriminatedUnion("type", [
5100
+ z8.object({
5101
+ key: z8.string().min(1),
5102
+ type: z8.literal("contentTypesMultiSelect"),
5103
+ label: z8.string().min(1),
5104
+ description: z8.string().min(1),
5105
+ defaultValue: z8.array(z8.string())
5106
+ }),
5107
+ z8.object({
5108
+ key: z8.string().min(1),
5109
+ type: z8.literal("number"),
5110
+ label: z8.string().min(1),
5111
+ description: z8.string().min(1),
5112
+ min: z8.number(),
5113
+ defaultValue: z8.number()
5114
+ })
5115
+ ]);
5116
+ var addonAdminModuleSchema = z8.object({
5117
+ addonId: z8.string().min(1),
5118
+ title: z8.string().min(1),
5119
+ description: z8.string().min(1),
5120
+ settingsNamespace: z8.string().min(1),
5121
+ checks: z8.array(z8.object({
5122
+ id: z8.string().min(1),
5123
+ label: z8.string().min(1),
5124
+ description: z8.string().min(1)
5125
+ })),
5126
+ settings: z8.object({
5127
+ title: z8.string().min(1),
5128
+ description: z8.string().min(1),
5129
+ fields: z8.array(addonAdminFieldSchema)
5130
+ })
5131
+ });
5132
+ function normalizeSlot(slot) {
5133
+ return {
5134
+ id: slot.id,
5135
+ title: slot.title,
5136
+ order: slot.order ?? 100
5137
+ };
5138
+ }
5139
+ function compareSlotOrder(left, right) {
5140
+ if ((left.order ?? 100) !== (right.order ?? 100)) {
5141
+ return (left.order ?? 100) - (right.order ?? 100);
5142
+ }
5143
+ return left.id.localeCompare(right.id);
5144
+ }
5145
+ function normalizeAddonSlots(manifest) {
5146
+ const dashboardWidgets = (manifest.slots.dashboardWidgets ?? []).map(normalizeSlot).sort(compareSlotOrder);
5147
+ const addonsSections = manifest.admin?.entry ? (manifest.slots.addonsSections ?? []).map(normalizeSlot).sort(compareSlotOrder) : [];
5148
+ return {
5149
+ dashboardWidgets,
5150
+ addonsSections
5151
+ };
5152
+ }
5153
+ function createFallbackAddonId(packageName) {
5154
+ return packageName.startsWith(ADDON_PACKAGE_PREFIX) ? packageName.slice(ADDON_PACKAGE_PREFIX.length) : packageName;
5155
+ }
5156
+ async function pathExists(path) {
5157
+ try {
5158
+ await access(path);
5159
+ return true;
5160
+ } catch {
5161
+ return false;
5162
+ }
5163
+ }
5164
+ async function resolvePackageJsonPath(packageName) {
5165
+ const hostRequire = getHostRequire();
5166
+ try {
5167
+ return hostRequire.resolve(`${packageName}/package.json`);
5168
+ } catch {
5169
+ try {
5170
+ const entryPath = hostRequire.resolve(packageName);
5171
+ let currentDir = dirname2(entryPath);
5172
+ for (; ; ) {
5173
+ const candidate = join5(currentDir, "package.json");
5174
+ if (await pathExists(candidate)) {
5175
+ return candidate;
5176
+ }
5177
+ const parentDir = dirname2(currentDir);
5178
+ if (parentDir === currentDir)
5179
+ return null;
5180
+ currentDir = parentDir;
5181
+ }
5182
+ } catch {
5183
+ return null;
5184
+ }
5185
+ }
5186
+ }
5187
+ async function resolvePackageRoot(packageName) {
5188
+ const packageJsonPath = await resolvePackageJsonPath(packageName);
5189
+ if (packageJsonPath)
5190
+ return dirname2(packageJsonPath);
5191
+ try {
5192
+ const manifestUrl = import.meta.resolve(`${packageName}/plank`);
5193
+ let currentDir = dirname2(fileURLToPath3(manifestUrl));
5194
+ for (; ; ) {
5195
+ const candidate = join5(currentDir, "package.json");
5196
+ if (await pathExists(candidate)) {
5197
+ return currentDir;
5198
+ }
5199
+ const parentDir = dirname2(currentDir);
5200
+ if (parentDir === currentDir)
5201
+ return null;
5202
+ currentDir = parentDir;
5203
+ }
5204
+ } catch {
5205
+ return null;
5206
+ }
5207
+ }
5208
+ async function readInstalledPackageJson(packageName) {
5209
+ const packageJsonPath = await resolvePackageJsonPath(packageName);
5210
+ if (!packageJsonPath)
5211
+ return null;
5212
+ try {
5213
+ const raw = await readFile3(packageJsonPath, "utf8");
5214
+ return JSON.parse(raw);
5215
+ } catch {
5216
+ return null;
5217
+ }
5218
+ }
5219
+ async function readHostPackageJson() {
5220
+ const raw = await readFile3(join5(process.cwd(), "package.json"), "utf8");
5221
+ return JSON.parse(raw);
5222
+ }
5223
+ function listDeclaredAddonPackages(packageJson) {
5224
+ return Array.from(/* @__PURE__ */ new Set([
5225
+ ...Object.keys(packageJson.dependencies ?? {}),
5226
+ ...Object.keys(packageJson.optionalDependencies ?? {})
5227
+ ])).filter((packageName) => packageName.startsWith(ADDON_PACKAGE_PREFIX)).sort();
5228
+ }
5229
+ function normalizeVersion2(value) {
5230
+ const [major = 0, minor = 0, patch = 0] = value.trim().replace(/^v/i, "").split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
5231
+ return [major, minor, patch];
5232
+ }
5233
+ function compareVersions2(left, right) {
5234
+ const leftParts = normalizeVersion2(left);
5235
+ const rightParts = normalizeVersion2(right);
5236
+ for (let index = 0; index < 3; index += 1) {
5237
+ if (leftParts[index] > rightParts[index])
5238
+ return 1;
5239
+ if (leftParts[index] < rightParts[index])
5240
+ return -1;
5241
+ }
5242
+ return 0;
5243
+ }
5244
+ function buildCaretUpperBound(version) {
5245
+ const [major, minor, patch] = normalizeVersion2(version);
5246
+ if (major > 0)
5247
+ return `${major + 1}.0.0`;
5248
+ if (minor > 0)
5249
+ return `0.${minor + 1}.0`;
5250
+ return `0.0.${patch + 1}`;
5251
+ }
5252
+ function buildTildeUpperBound(version) {
5253
+ const [major, minor] = normalizeVersion2(version);
5254
+ return `${major}.${minor + 1}.0`;
5255
+ }
5256
+ function satisfiesComparator(version, comparator) {
5257
+ const value = comparator.trim();
5258
+ if (!value)
5259
+ return true;
5260
+ if (value.startsWith(">="))
5261
+ return compareVersions2(version, value.slice(2)) >= 0;
5262
+ if (value.startsWith("<="))
5263
+ return compareVersions2(version, value.slice(2)) <= 0;
5264
+ if (value.startsWith(">"))
5265
+ return compareVersions2(version, value.slice(1)) > 0;
5266
+ if (value.startsWith("<"))
5267
+ return compareVersions2(version, value.slice(1)) < 0;
5268
+ if (value.startsWith("^")) {
5269
+ const lower = value.slice(1);
5270
+ return compareVersions2(version, lower) >= 0 && compareVersions2(version, buildCaretUpperBound(lower)) < 0;
5271
+ }
5272
+ if (value.startsWith("~")) {
5273
+ const lower = value.slice(1);
5274
+ return compareVersions2(version, lower) >= 0 && compareVersions2(version, buildTildeUpperBound(lower)) < 0;
5275
+ }
5276
+ return compareVersions2(version, value) === 0;
5277
+ }
5278
+ function satisfiesVersionRange(version, range) {
5279
+ const normalizedRange = range.trim();
5280
+ if (!normalizedRange || normalizedRange === "*")
5281
+ return true;
5282
+ return normalizedRange.split("||").some((part) => part.trim().split(/\s+/).every((token) => satisfiesComparator(version, token)));
5283
+ }
5284
+ async function loadAddonManifest(packageName) {
5285
+ const module = await import(`${packageName}/plank`);
5286
+ const parsed = addonManifestSchema.safeParse(module?.manifest);
5287
+ if (!parsed.success) {
5288
+ throw new Error(parsed.error.issues.map((issue) => issue.message).join(", "));
5289
+ }
5290
+ if (parsed.data.packageName !== packageName) {
5291
+ throw new Error(`Manifest packageName mismatch for ${packageName}`);
5292
+ }
5293
+ return parsed.data;
5294
+ }
5295
+ async function loadAddonAdminModule(packageName) {
5296
+ const module = await import(`${packageName}/admin`);
5297
+ const candidate = module?.adminModule ?? module?.default;
5298
+ const parsed = addonAdminModuleSchema.safeParse(candidate);
5299
+ if (!parsed.success) {
5300
+ throw new Error(parsed.error.issues.map((issue) => issue.message).join(", "));
5301
+ }
5302
+ return parsed.data;
5303
+ }
5304
+ async function loadAddonServerModule(packageName) {
5305
+ const module = await import(`${packageName}/server`);
5306
+ const candidate = module?.serverModule ?? module?.default;
5307
+ if (!candidate || typeof candidate.runAction !== "function") {
5308
+ throw new Error(`Invalid server module for ${packageName}`);
5309
+ }
5310
+ return candidate;
5311
+ }
5312
+ async function resolveAddonAdminEntryPath(packageName) {
5313
+ const [manifest, packageRoot] = await Promise.all([
5314
+ loadAddonManifest(packageName),
5315
+ resolvePackageRoot(packageName)
5316
+ ]);
5317
+ if (!manifest.admin?.entry || !packageRoot)
5318
+ return null;
5319
+ const entryPath = resolve(packageRoot, manifest.admin.entry);
5320
+ if (!entryPath.startsWith(packageRoot)) {
5321
+ throw new Error(`Invalid admin entry path for ${packageName}`);
5322
+ }
5323
+ if (!await pathExists(entryPath)) {
5324
+ throw new Error(`Admin entry file not found for ${packageName}: ${entryPath}`);
5325
+ }
5326
+ return entryPath;
5327
+ }
5328
+ async function discoverAddon(packageName, coreVersion) {
5329
+ const packageJson = await readInstalledPackageJson(packageName);
5330
+ try {
5331
+ const manifest = await loadAddonManifest(packageName);
5332
+ const compatible = satisfiesVersionRange(coreVersion, manifest.plankRange);
5333
+ return {
5334
+ id: manifest.id,
5335
+ packageName,
5336
+ name: manifest.name,
5337
+ version: manifest.version,
5338
+ plankRange: manifest.plankRange,
5339
+ description: manifest.description ?? packageJson?.description ?? null,
5340
+ installed: true,
5341
+ enabled: false,
5342
+ compatible,
5343
+ hasAdminUi: Boolean(manifest.admin?.entry),
5344
+ settingsNamespace: manifest.settingsNamespace ?? null,
5345
+ slots: normalizeAddonSlots(manifest)
5346
+ };
5347
+ } catch (error) {
5348
+ const fallbackId = createFallbackAddonId(packageName);
5349
+ const message = error instanceof Error ? error.message : "Unknown manifest error";
5350
+ console.warn(`[plank/addons] Skipping invalid add-on manifest for ${packageName}: ${message}`);
5351
+ return {
5352
+ id: fallbackId,
5353
+ packageName,
5354
+ name: packageJson?.name ?? fallbackId,
5355
+ version: packageJson?.version ?? null,
5356
+ plankRange: null,
5357
+ description: packageJson?.description ?? null,
5358
+ installed: true,
5359
+ enabled: false,
5360
+ compatible: false,
5361
+ hasAdminUi: false,
5362
+ settingsNamespace: null,
5363
+ slots: {
5364
+ dashboardWidgets: [],
5365
+ addonsSections: []
5366
+ }
5367
+ };
5368
+ }
5369
+ }
5370
+ async function syncInstalledAddons() {
5371
+ const hostPackageJson = await readHostPackageJson();
5372
+ const packageNames = listDeclaredAddonPackages(hostPackageJson);
5373
+ const coreVersion = await getCurrentVersion();
5374
+ const discovered = (await Promise.all(packageNames.map((packageName) => discoverAddon(packageName, coreVersion)))).filter((addon) => addon !== null);
5375
+ const client = await pool_default.connect();
5376
+ try {
5377
+ await client.query("BEGIN");
5378
+ for (const addon of discovered) {
5379
+ await client.query(`INSERT INTO plank_addons (
5380
+ id,
5381
+ package_name,
5382
+ name,
5383
+ version,
5384
+ plank_range,
5385
+ description,
5386
+ installed,
5387
+ compatible,
5388
+ has_admin_ui,
5389
+ settings_namespace,
5390
+ slots_json
5391
+ )
5392
+ VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7, $8, $9, $10::jsonb)
5393
+ ON CONFLICT (id) DO UPDATE SET
5394
+ package_name = EXCLUDED.package_name,
5395
+ name = EXCLUDED.name,
5396
+ version = EXCLUDED.version,
5397
+ plank_range = EXCLUDED.plank_range,
5398
+ description = EXCLUDED.description,
5399
+ installed = EXCLUDED.installed,
5400
+ compatible = EXCLUDED.compatible,
5401
+ has_admin_ui = EXCLUDED.has_admin_ui,
5402
+ settings_namespace = EXCLUDED.settings_namespace,
5403
+ slots_json = EXCLUDED.slots_json,
5404
+ updated_at = NOW()`, [
5405
+ addon.id,
5406
+ addon.packageName,
5407
+ addon.name,
5408
+ addon.version,
5409
+ addon.plankRange,
5410
+ addon.description,
5411
+ addon.compatible,
5412
+ addon.hasAdminUi,
5413
+ addon.settingsNamespace,
5414
+ JSON.stringify(addon.slots)
5415
+ ]);
5416
+ }
5417
+ if (packageNames.length > 0) {
5418
+ await client.query(`UPDATE plank_addons
5419
+ SET installed = FALSE,
5420
+ compatible = FALSE,
5421
+ has_admin_ui = FALSE,
5422
+ slots_json = '{}'::jsonb,
5423
+ updated_at = NOW()
5424
+ WHERE package_name LIKE $1
5425
+ AND package_name <> ALL($2::text[])`, [`${ADDON_PACKAGE_PREFIX}%`, packageNames]);
5426
+ } else {
5427
+ await client.query(`UPDATE plank_addons
5428
+ SET installed = FALSE,
5429
+ compatible = FALSE,
5430
+ has_admin_ui = FALSE,
5431
+ slots_json = '{}'::jsonb,
5432
+ updated_at = NOW()
5433
+ WHERE package_name LIKE $1`, [`${ADDON_PACKAGE_PREFIX}%`]);
5434
+ }
5435
+ await client.query("COMMIT");
5436
+ } catch (error) {
5437
+ await client.query("ROLLBACK");
5438
+ throw error;
5439
+ } finally {
5440
+ client.release();
5441
+ }
5442
+ }
5443
+ function normalizeSlotsFromRow(value) {
5444
+ return {
5445
+ dashboardWidgets: Array.isArray(value?.dashboardWidgets) ? value.dashboardWidgets : [],
5446
+ addonsSections: Array.isArray(value?.addonsSections) ? value.addonsSections : []
5447
+ };
5448
+ }
5449
+ function mapAddonRow(row) {
5450
+ return {
5451
+ id: row.id,
5452
+ packageName: row.package_name,
5453
+ name: row.name,
5454
+ version: row.version ?? "",
5455
+ ...row.description ? { description: row.description } : {},
5456
+ installed: row.installed,
5457
+ enabled: row.enabled,
5458
+ compatible: row.compatible,
5459
+ hasAdminUi: row.has_admin_ui,
5460
+ ...row.settings_namespace ? { settingsNamespace: row.settings_namespace } : {},
5461
+ ...row.has_admin_ui ? { adminUrl: `/admin/add-ons/${row.id}` } : {}
5462
+ };
5463
+ }
5464
+ async function listAddonRows() {
5465
+ const { rows } = await pool_default.query(`SELECT
5466
+ id,
5467
+ package_name,
5468
+ name,
5469
+ version,
5470
+ plank_range,
5471
+ description,
5472
+ installed,
5473
+ enabled,
5474
+ compatible,
5475
+ has_admin_ui,
5476
+ settings_namespace,
5477
+ slots_json
5478
+ FROM plank_addons
5479
+ ORDER BY name ASC, id ASC`);
5480
+ return rows;
5481
+ }
5482
+ async function getAddonRow(id) {
5483
+ const { rows } = await pool_default.query(`SELECT
5484
+ id,
5485
+ package_name,
5486
+ name,
5487
+ version,
5488
+ plank_range,
5489
+ description,
5490
+ installed,
5491
+ enabled,
5492
+ compatible,
5493
+ has_admin_ui,
5494
+ settings_namespace,
5495
+ slots_json
5496
+ FROM plank_addons
5497
+ WHERE id = $1`, [id]);
5498
+ return rows[0] ?? null;
5499
+ }
5500
+ async function getAddonAdminModule(id) {
5501
+ const addon = await getAddonRow(id);
5502
+ if (!addon || !addon.installed || !addon.has_admin_ui)
5503
+ return null;
5504
+ try {
5505
+ return await loadAddonAdminModule(addon.package_name);
5506
+ } catch (error) {
5507
+ const message = error instanceof Error ? error.message : "Unknown admin module error";
5508
+ console.warn(`[plank/addons] Failed to load admin module for ${addon.package_name}: ${message}`);
5509
+ return null;
5510
+ }
5511
+ }
5512
+ async function getAddonAdminEntryPath(id) {
5513
+ const addon = await getAddonRow(id);
5514
+ if (!addon || !addon.installed || !addon.has_admin_ui)
5515
+ return null;
5516
+ try {
5517
+ return await resolveAddonAdminEntryPath(addon.package_name);
5518
+ } catch (error) {
5519
+ const message = error instanceof Error ? error.message : "Unknown admin entry error";
5520
+ console.warn(`[plank/addons] Failed to resolve admin entry for ${addon.package_name}: ${message}`);
5521
+ return null;
5522
+ }
5523
+ }
5524
+ async function runAddonServerAction(id, action, input) {
5525
+ const addon = await getAddonRow(id);
5526
+ if (!addon) {
5527
+ throw new Error("Addon not found");
5528
+ }
5529
+ if (!addon.installed) {
5530
+ throw new Error("Addon is not installed");
5531
+ }
5532
+ if (!addon.enabled) {
5533
+ throw new Error("Addon is disabled");
5534
+ }
5535
+ if (!addon.compatible) {
5536
+ throw new Error("Addon is not compatible with this Plank version");
5537
+ }
5538
+ const serverModule = await loadAddonServerModule(addon.package_name);
5539
+ return serverModule.runAction({
5540
+ action,
5541
+ input,
5542
+ addon: {
5543
+ id: addon.id,
5544
+ packageName: addon.package_name,
5545
+ settingsNamespace: addon.settings_namespace
5546
+ },
5547
+ context: {
5548
+ db: {
5549
+ query: pool_default.query.bind(pool_default)
5550
+ },
5551
+ getSettings,
5552
+ findAllContentTypes,
5553
+ findContentTypeBySlug,
5554
+ quoteIdentifier
5555
+ }
5556
+ });
5557
+ }
5558
+ async function updateAddonEnabled(id, enabled) {
5559
+ const { rows } = await pool_default.query(`UPDATE plank_addons
5560
+ SET enabled = $2, updated_at = NOW()
5561
+ WHERE id = $1
5562
+ RETURNING
5563
+ id,
5564
+ package_name,
5565
+ name,
5566
+ version,
5567
+ plank_range,
5568
+ description,
5569
+ installed,
5570
+ enabled,
5571
+ compatible,
5572
+ has_admin_ui,
5573
+ settings_namespace,
5574
+ slots_json`, [id, enabled]);
5575
+ return rows[0] ?? null;
5576
+ }
5577
+ async function buildAdminAddonsRegistry() {
5578
+ const rows = await listAddonRows();
5579
+ const enabledRows = rows.filter((row) => row.installed && row.enabled && row.compatible);
5580
+ return {
5581
+ addons: rows.map(mapAddonRow),
5582
+ slots: {
5583
+ dashboardWidgets: enabledRows.flatMap((row) => normalizeSlotsFromRow(row.slots_json).dashboardWidgets.map((slot) => ({
5584
+ addonId: row.id,
5585
+ slotId: slot.id,
5586
+ title: slot.title,
5587
+ order: slot.order ?? 100
5588
+ }))),
5589
+ addonsSections: enabledRows.flatMap((row) => normalizeSlotsFromRow(row.slots_json).addonsSections.map((slot) => ({
5590
+ addonId: row.id,
5591
+ slotId: slot.id,
5592
+ title: slot.title,
5593
+ order: slot.order ?? 100
5594
+ })))
5595
+ }
5596
+ };
5597
+ }
5598
+
5599
+ // ../core/dist/controllers/addons.js
5600
+ function mapAddon(row) {
5601
+ return {
5602
+ id: row.id,
5603
+ packageName: row.package_name,
5604
+ name: row.name,
5605
+ version: row.version ?? "",
5606
+ ...row.description ? { description: row.description } : {},
5607
+ installed: row.installed,
5608
+ enabled: row.enabled,
5609
+ compatible: row.compatible,
5610
+ hasAdminUi: row.has_admin_ui,
5611
+ ...row.settings_namespace ? { settingsNamespace: row.settings_namespace } : {}
5612
+ };
5613
+ }
5614
+ async function getSettingsAddon(id) {
5615
+ const addon = await getAddonRow(id);
5616
+ if (!addon)
5617
+ return { error: "Addon not found" };
5618
+ if (!addon.settings_namespace)
5619
+ return { error: "Addon does not expose settings" };
5620
+ return { addon };
5621
+ }
5622
+ async function getAddonsRegistry(_req, res) {
5623
+ const registry = await buildAdminAddonsRegistry();
5624
+ res.json(registry);
5625
+ }
5626
+ async function listAddons(_req, res) {
5627
+ const addons = await listAddonRows();
5628
+ res.json(addons.map(mapAddon));
5629
+ }
5630
+ async function enableAddon(req, res) {
5631
+ const addon = await getAddonRow(req.params.id);
5632
+ if (!addon) {
5633
+ res.status(404).json({ error: "Addon not found" });
5634
+ return;
5635
+ }
5636
+ if (!addon.installed) {
5637
+ res.status(409).json({ error: "Addon is not installed" });
5638
+ return;
5639
+ }
5640
+ if (!addon.compatible) {
5641
+ res.status(409).json({ error: "Addon is not compatible with this Plank version" });
5642
+ return;
5643
+ }
5644
+ const updated = await updateAddonEnabled(req.params.id, true);
5645
+ res.json(mapAddon(updated));
5646
+ }
5647
+ async function disableAddon(req, res) {
5648
+ const addon = await getAddonRow(req.params.id);
5649
+ if (!addon) {
5650
+ res.status(404).json({ error: "Addon not found" });
5651
+ return;
5652
+ }
5653
+ const updated = await updateAddonEnabled(req.params.id, false);
5654
+ res.json(mapAddon(updated));
5655
+ }
5656
+ async function getAddonSettings(req, res) {
5657
+ const result = await getSettingsAddon(req.params.id);
5658
+ if ("error" in result) {
5659
+ res.status(result.error === "Addon not found" ? 404 : 400).json({ error: result.error });
5660
+ return;
5661
+ }
5662
+ const settings = await getSettings(result.addon.settings_namespace);
5663
+ res.json(settings);
5664
+ }
5665
+ async function getAddonAdminModuleDefinition(req, res) {
5666
+ const addon = await getAddonAdminModule(req.params.id);
5667
+ if (!addon) {
5668
+ res.status(404).json({ error: "Addon admin module not found" });
5669
+ return;
5670
+ }
5671
+ res.json(addon);
5672
+ }
5673
+ async function getAddonAdminEntry(req, res) {
5674
+ const entryPath = await getAddonAdminEntryPath(req.params.id);
5675
+ if (!entryPath) {
5676
+ res.status(404).json({ error: "Addon admin entry not found" });
5677
+ return;
5678
+ }
5679
+ res.type("application/javascript");
5680
+ res.sendFile(entryPath);
5681
+ }
5682
+ async function runAddonAction(req, res) {
5683
+ const parsed = z9.object({
5684
+ action: z9.string().min(1),
5685
+ input: z9.unknown().optional()
5686
+ }).safeParse(req.body);
5687
+ if (!parsed.success) {
5688
+ res.status(400).json({ error: "Body must include a valid action name" });
5689
+ return;
5690
+ }
5691
+ try {
5692
+ const result = await runAddonServerAction(req.params.id, parsed.data.action, parsed.data.input);
5693
+ res.json({ result });
5694
+ } catch (error) {
5695
+ const message = error instanceof Error ? error.message : "Could not run add-on action";
5696
+ const status = message === "Addon not found" ? 404 : message === "Addon is not installed" ? 409 : message === "Addon is disabled" || message === "Addon is not compatible with this Plank version" ? 409 : 400;
5697
+ res.status(status).json({ error: message });
5698
+ }
5699
+ }
5700
+ async function updateAddonSettings(req, res) {
5701
+ const result = await getSettingsAddon(req.params.id);
5702
+ if ("error" in result) {
5703
+ res.status(result.error === "Addon not found" ? 404 : 400).json({ error: result.error });
5704
+ return;
5705
+ }
5706
+ const parsed = z9.record(z9.string(), z9.string()).safeParse(req.body);
5707
+ if (!parsed.success) {
5708
+ res.status(400).json({ error: "Body must be a flat key-value object" });
5709
+ return;
5710
+ }
5711
+ await setSettings(result.addon.settings_namespace, parsed.data);
5712
+ const settings = await getSettings(result.addon.settings_namespace);
5713
+ res.json(settings);
5714
+ }
5715
+
5006
5716
  // ../core/dist/routes/admin.js
5007
5717
  var router2 = Router2();
5008
5718
  router2.use(authenticate);
@@ -5061,6 +5771,15 @@ router2.get("/editorial-mode", getEditorialMode);
5061
5771
  router2.get("/version", getVersionInfo);
5062
5772
  router2.get("/settings/:namespace", authorize("settings:overview:read"), getNamespaceSettings);
5063
5773
  router2.put("/settings/:namespace", authorize("settings:overview:write"), updateNamespaceSettings);
5774
+ router2.get("/addons/registry", authorize("addons:read"), getAddonsRegistry);
5775
+ router2.get("/addons", authorize("addons:read"), listAddons);
5776
+ router2.post("/addons/:id/enable", authorize("addons:write"), enableAddon);
5777
+ router2.post("/addons/:id/disable", authorize("addons:write"), disableAddon);
5778
+ router2.get("/addons/:id/admin-module", authorize("addons:read"), getAddonAdminModuleDefinition);
5779
+ router2.get("/addons/:id/admin-entry.js", authorize("addons:read"), getAddonAdminEntry);
5780
+ router2.post("/addons/:id/actions", authorize("addons:read"), runAddonAction);
5781
+ router2.get("/addons/:id/settings", authorize("addons:read"), getAddonSettings);
5782
+ router2.put("/addons/:id/settings", authorize("addons:write"), updateAddonSettings);
5064
5783
  router2.get("/webhooks", authorize("settings:webhooks:read"), listWebhooks);
5065
5784
  router2.post("/webhooks", authorize("settings:webhooks:write"), createWebhook);
5066
5785
  router2.delete("/webhooks/:id", authorize("settings:webhooks:delete"), deleteWebhook);
@@ -5772,9 +6491,9 @@ if (isDev) {
5772
6491
  app.get("/admin/*path", (_req, res) => res.redirect(adminDevUrl));
5773
6492
  app.get("/admin", (_req, res) => res.redirect(adminDevUrl));
5774
6493
  } else {
5775
- const adminDist = process.env.PLANK_ADMIN_DIST ?? join4(dirname2(fileURLToPath3(import.meta.url)), "../public/admin");
6494
+ const adminDist = process.env.PLANK_ADMIN_DIST ?? join6(dirname3(fileURLToPath4(import.meta.url)), "../public/admin");
5776
6495
  app.use("/admin", express.static(adminDist));
5777
- app.get("/admin/*path", (_req, res) => res.sendFile(join4(adminDist, "index.html")));
6496
+ app.get("/admin/*path", (_req, res) => res.sendFile(join6(adminDist, "index.html")));
5778
6497
  }
5779
6498
  app.use(errorHandler);
5780
6499
  var app_default = app;
@@ -5785,6 +6504,7 @@ async function start() {
5785
6504
  const isDev2 = !process.env.PLANK_ADMIN_DIST;
5786
6505
  await migrate();
5787
6506
  await syncAllTables();
6507
+ await syncInstalledAddons();
5788
6508
  app_default.listen(PORT2, () => {
5789
6509
  const coreBase = `http://localhost:${PORT2}`;
5790
6510
  const adminUrl = isDev2 ? "http://localhost:3000" : `${coreBase}/admin`;