@podosoft/podokit 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/add.d.ts +3 -1
  2. package/dist/add.js +116 -14
  3. package/dist/index.js +8 -0
  4. package/dist/lib.d.ts +2 -2
  5. package/dist/lib.js +3 -2
  6. package/dist/lockfile.d.ts +3 -1
  7. package/dist/lockfile.js +17 -6
  8. package/dist/templates/fullstack/apps/api/scripts/build.mjs +1 -0
  9. package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +1 -0
  10. package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +15 -0
  11. package/dist/templates/fullstack/apps/api/src/database/data-source.ts +3 -0
  12. package/dist/templates/fullstack/apps/api/src/database/migrate.ts +1 -1
  13. package/dist/templates/fullstack/apps/web/package.json +1 -1
  14. package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +8 -1
  15. package/dist/templates/fullstack/tests/playwright.extensions.ts +2 -2
  16. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +21 -26
  17. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +74 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.spec.ts +76 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.routes.ts +41 -0
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.spec.ts +124 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-users/admin-users.service.ts +153 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +5 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +60 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +35 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +59 -32
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +81 -11
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.test.ts +23 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/guards.ts +2 -0
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/public-paths.ts +9 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +13 -19
  31. package/dist/templates/modules/admin-dashboard/files/tests/api/admin-users.api.spec.ts +86 -0
  32. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +1 -0
  33. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +1 -1
  34. package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -2
  35. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +2 -2
  36. package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +0 -1
  37. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -3
  38. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +3 -1
  39. package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +14 -15
  40. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +13 -8
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +14 -3
  43. package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
  44. package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +53 -11
  45. package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +26 -4
  46. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.spec.ts +53 -0
  47. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.rows.ts +65 -0
  48. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +16 -37
  49. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.spec.ts +33 -0
  50. package/dist/templates/modules/auth/files/apps/api/src/auth-config/session-expiry.ts +29 -0
  51. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +2 -0
  52. package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +2 -2
  53. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +48 -1
  54. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +15 -2
  55. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +35 -0
  56. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +18 -1
  57. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  58. package/dist/templates/modules/sse/module.manifest.json +3 -1
  59. package/dist/update.d.ts +17 -0
  60. package/dist/update.js +33 -8
  61. package/package.json +1 -1
package/dist/add.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type TemplateVars } from "@podosoft/podokit-template-engine";
2
- import { type ProviderCapability } from "@podosoft/podokit-runtime";
2
+ import { type ProviderCapability, type ProviderSelections } from "@podosoft/podokit-runtime";
3
3
  interface Injection {
4
4
  file: string;
5
5
  marker: string;
@@ -127,6 +127,8 @@ export declare function resolveModuleDir(name: string, modulesDir: string, proje
127
127
  /** Resolve a module and capture the package identity needed to replay external
128
128
  * modules during `podo update`. */
129
129
  export declare function resolveModule(name: string, modulesDir: string, projectRoot: string): ResolvedModule | null;
130
+ /** Name of the module that implements the selected provider for a capability. */
131
+ export declare function selectedProviderModule(modulesDir: string, projectRoot: string, providers: ProviderSelections, capability: ProviderCapability): string;
130
132
  /** List modules available to a project: the bundled ones under `modulesDir` plus,
131
133
  * when `projectRoot` is given, any `@podosoft/podokit-module-*` packages installed
132
134
  * in it. Bundled names take precedence on a clash. */
package/dist/add.js CHANGED
@@ -4,6 +4,7 @@ exports.SUPPORTED_MANIFEST_VERSION = void 0;
4
4
  exports.modulePackageOverlays = modulePackageOverlays;
5
5
  exports.resolveModuleDir = resolveModuleDir;
6
6
  exports.resolveModule = resolveModule;
7
+ exports.selectedProviderModule = selectedProviderModule;
7
8
  exports.listModules = listModules;
8
9
  exports.readModuleManifest = readModuleManifest;
9
10
  exports.addModule = addModule;
@@ -106,11 +107,9 @@ function assertManifestVersion(name, manifest) {
106
107
  throw new Error(`Module "${name}" declares an invalid maxApiReplicas constraint.`);
107
108
  }
108
109
  }
109
- function providerModuleName(projectRoot, modulesDir, capability) {
110
- const project = (0, lockfile_1.readManifest)(projectRoot);
111
- if (!project)
112
- throw new Error("Provider capabilities require a PodoKit project manifest.");
113
- const selected = project.providers[capability];
110
+ /** Name of the module that implements the selected provider for a capability. */
111
+ function selectedProviderModule(modulesDir, projectRoot, providers, capability) {
112
+ const selected = providers[capability];
114
113
  for (const available of listModules(modulesDir, projectRoot)) {
115
114
  const resolved = resolveModule(available.name, modulesDir, projectRoot);
116
115
  if (!resolved)
@@ -122,6 +121,12 @@ function providerModuleName(projectRoot, modulesDir, capability) {
122
121
  throw new Error(`No module provides the selected ${capability} provider "${selected}". ` +
123
122
  `Choose another provider with podo provider set ${capability} <provider> --apply.`);
124
123
  }
124
+ function providerModuleName(projectRoot, modulesDir, capability) {
125
+ const project = (0, lockfile_1.readManifest)(projectRoot);
126
+ if (!project)
127
+ throw new Error("Provider capabilities require a PodoKit project manifest.");
128
+ return selectedProviderModule(modulesDir, projectRoot, project.providers, capability);
129
+ }
125
130
  function requiredModules(projectRoot, modulesDir, manifest) {
126
131
  const capabilities = (manifest.requiresCapabilities ?? []).map((capability) => providerModuleName(projectRoot, modulesDir, capability));
127
132
  return [...new Set([...(manifest.requires ?? []), ...capabilities])];
@@ -217,14 +222,24 @@ function projectName(projectRoot) {
217
222
  const pkg = readJson((0, node_path_1.join)(projectRoot, "package.json"));
218
223
  return typeof pkg.name === "string" ? pkg.name : "app";
219
224
  }
220
- function appendEnv(projectRoot, lines) {
225
+ function appendEnv(projectRoot, lines, anchors = []) {
221
226
  const file = (0, node_path_1.join)(projectRoot, ".env.example");
222
227
  if (!(0, node_fs_1.existsSync)(file))
223
228
  return;
224
229
  const current = (0, node_fs_1.readFileSync)(file, "utf8");
225
- const missing = lines.filter((line) => !current.split("\n").includes(line));
230
+ const existing = current.split("\n");
231
+ const missing = lines.filter((line) => !existing.includes(line));
226
232
  if (missing.length === 0)
227
233
  return;
234
+ // Each module appends its own blank-line separated block. Keep that shape
235
+ // when the block belongs in front of a displaced module's block.
236
+ const wanted = new Set(anchors);
237
+ const index = existing.findIndex((line, position) => position > 0 && existing[position - 1] === "" && wanted.has(line));
238
+ if (index !== -1) {
239
+ existing.splice(index, 0, ...missing, "");
240
+ (0, node_fs_1.writeFileSync)(file, existing.join("\n"));
241
+ return;
242
+ }
228
243
  const separator = current.endsWith("\n") ? "" : "\n";
229
244
  (0, node_fs_1.writeFileSync)(file, `${current}${separator}\n${missing.join("\n")}\n`);
230
245
  }
@@ -246,6 +261,91 @@ function isApplied(projectRoot, modulesDir, module) {
246
261
  }
247
262
  return false;
248
263
  }
264
+ function injectionKey(file, marker) {
265
+ return `${file}\n${marker}`;
266
+ }
267
+ /** Modules `applyModule` would newly apply, in application order. */
268
+ function plannedModules(projectRoot, module, modulesDir, planned = new Set(), order = []) {
269
+ planned.add(module);
270
+ const resolved = resolveModule(module, modulesDir, projectRoot);
271
+ if (!resolved)
272
+ return order;
273
+ for (const required of requiredModules(projectRoot, modulesDir, readModuleManifest(resolved.dir))) {
274
+ if (planned.has(required) || isApplied(projectRoot, modulesDir, required))
275
+ continue;
276
+ plannedModules(projectRoot, required, modulesDir, planned, order);
277
+ }
278
+ order.push(module);
279
+ return order;
280
+ }
281
+ function modulePlacement(projectRoot, module, modulesDir) {
282
+ const project = (0, lockfile_1.readManifest)(projectRoot);
283
+ if (!project)
284
+ return undefined;
285
+ const installed = [...project.modules].sort((a, b) => a.order - b.order);
286
+ const installedNames = new Set(installed.map((entry) => entry.name));
287
+ const fresh = new Set(plannedModules(projectRoot, module, modulesDir).filter((name) => !installedNames.has(name)));
288
+ if (!fresh.size)
289
+ return undefined;
290
+ const provided = new Set();
291
+ for (const name of fresh) {
292
+ const resolved = resolveModule(name, modulesDir, projectRoot);
293
+ if (!resolved)
294
+ continue;
295
+ for (const capability of Object.keys(readModuleManifest(resolved.dir).provides ?? {})) {
296
+ provided.add(capability);
297
+ }
298
+ }
299
+ const vars = {
300
+ projectName: projectName(projectRoot),
301
+ ...(0, lockfile_1.manifestTemplateVars)(project),
302
+ };
303
+ const manifests = installed.map((entry) => {
304
+ const resolved = resolveModule(entry.packageName ?? entry.name, modulesDir, projectRoot);
305
+ return resolved ? readModuleManifest(resolved.dir, vars) : undefined;
306
+ });
307
+ const first = manifests.findIndex((manifest) => manifest !== undefined && ((manifest.requires ?? []).some((name) => fresh.has(name))
308
+ || (manifest.requiresCapabilities ?? []).some((capability) => provided.has(capability))));
309
+ if (first === -1)
310
+ return undefined;
311
+ const injections = new Map();
312
+ const env = [];
313
+ for (const manifest of manifests.slice(first)) {
314
+ if (!manifest)
315
+ continue;
316
+ for (const injection of manifest.inject ?? []) {
317
+ const key = injectionKey(injection.file, injection.marker);
318
+ injections.set(key, [...(injections.get(key) ?? []), injection.text]);
319
+ }
320
+ env.push(...(manifest.env ?? []));
321
+ }
322
+ return { before: installed[first].name, injections, env };
323
+ }
324
+ /** Insert wiring at its marker, or in front of the first displaced module's wiring. */
325
+ function injectAt(content, injection, anchors = []) {
326
+ if (!anchors.length || content.includes(injection.text)) {
327
+ return (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text);
328
+ }
329
+ const lines = content.split("\n");
330
+ const end = lines.findIndex((line) => line.includes(injection.marker));
331
+ if (end === -1)
332
+ throw new Error(`Marker not found: ${injection.marker}`);
333
+ const beginMarker = injection.marker.replace("podokit:end:", "podokit:begin:");
334
+ const begin = beginMarker === injection.marker
335
+ ? -1
336
+ : lines.slice(0, end).findIndex((line) => line.includes(beginMarker));
337
+ const wanted = new Set(anchors.map((text) => text.split("\n")[0].trim()));
338
+ let index = end;
339
+ for (let line = begin + 1; line < end; line += 1) {
340
+ if (wanted.has(lines[line].trim())) {
341
+ index = line;
342
+ break;
343
+ }
344
+ }
345
+ const indent = lines[end].match(/^\s*/)?.[0] ?? "";
346
+ lines.splice(index, 0, `${indent}${injection.text}`);
347
+ return lines.join("\n");
348
+ }
249
349
  /**
250
350
  * Apply a module to an existing generated project: overlay files, merge the
251
351
  * target app's package.json dependencies and scripts, append env example lines,
@@ -262,7 +362,8 @@ function addModule(options) {
262
362
  })
263
363
  .map(([path]) => path)
264
364
  : [];
265
- const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false);
365
+ const placement = modulePlacement(options.projectRoot, options.module, options.modulesDir);
366
+ const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false, placement);
266
367
  if (options.adopt && result.adopted.length) {
267
368
  releaseOwnedPaths(options.projectRoot, result.adopted);
268
369
  }
@@ -279,10 +380,10 @@ function addModule(options) {
279
380
  modulePaths: [...new Set(result.touched)],
280
381
  adoptedPaths: result.adopted,
281
382
  }
282
- : undefined, result.managedOverrides);
383
+ : undefined, result.managedOverrides, placement?.before);
283
384
  return result;
284
385
  }
285
- function applyModule(projectRoot, module, modulesDir, applied, adopt) {
386
+ function applyModule(projectRoot, module, modulesDir, applied, adopt, placement) {
286
387
  const resolved = resolveModule(module, modulesDir, projectRoot);
287
388
  if (!resolved) {
288
389
  const available = listModules(modulesDir, projectRoot).map((m) => m.name);
@@ -318,8 +419,8 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
318
419
  for (const required of requiredModules(projectRoot, modulesDir, manifest)) {
319
420
  if (applied.has(required) || isApplied(projectRoot, modulesDir, required))
320
421
  continue;
321
- const result = applyModule(projectRoot, required, modulesDir, applied, adopt);
322
- added.push(required, ...result.added);
422
+ const result = applyModule(projectRoot, required, modulesDir, applied, adopt, placement);
423
+ added.push(...result.added, required);
323
424
  ownedGlobs.push(...result.ownedGlobs);
324
425
  managedOverrides.push(...result.managedOverrides);
325
426
  preserved.push(...result.preserved);
@@ -356,7 +457,7 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
356
457
  }
357
458
  // 3) append env example lines
358
459
  if (manifest.env?.length) {
359
- appendEnv(projectRoot, manifest.env);
460
+ appendEnv(projectRoot, manifest.env, placement?.env);
360
461
  touched.push(".env.example");
361
462
  }
362
463
  // 4) inject wiring at markers
@@ -370,7 +471,8 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
370
471
  const content = (0, node_fs_1.readFileSync)(target, "utf8");
371
472
  if (injection.optional && !content.includes(injection.marker))
372
473
  continue;
373
- (0, node_fs_1.writeFileSync)(target, (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text));
474
+ const anchors = placement?.injections.get(injectionKey(injection.file, injection.marker));
475
+ (0, node_fs_1.writeFileSync)(target, injectAt(content, injection, anchors));
374
476
  touched.push(injection.file);
375
477
  }
376
478
  const instructions = (manifest.instructions ?? []).map((line) => line.replace(/<app>/g, appName));
package/dist/index.js CHANGED
@@ -652,6 +652,9 @@ async function main(argv) {
652
652
  try {
653
653
  if (args.apply) {
654
654
  const result = (0, update_1.applyUpdate)(process.cwd(), templatesDir, { oldTemplatesDir: args.from });
655
+ if (result.addedModules.length) {
656
+ process.stdout.write(`Added required module(s): ${result.addedModules.join(", ")}\n`);
657
+ }
655
658
  process.stdout.write(`Applied: ${result.written.length} written, ${result.moved.length} moved, ` +
656
659
  `${result.removed.length} removed, ` +
657
660
  `${result.merged.length} merged, ${result.conflicts.length} conflict.\n`);
@@ -666,6 +669,11 @@ async function main(argv) {
666
669
  const counts = (0, update_1.summarize)(plan);
667
670
  const shown = plan.changes.filter((c) => c.action !== "up-to-date" && c.action !== "skip");
668
671
  process.stdout.write(`podo update ${plan.fromVersion} -> ${plan.toVersion} (template: ${plan.template}; modules: ${plan.modules.join(", ") || "none"})\n\n`);
672
+ if (plan.addedModules.length) {
673
+ process.stdout.write("Modules to add:\n" +
674
+ plan.addedModules.map((module) => ` ${module.name} (${(0, update_1.addedModuleReason)(module)})`).join("\n") +
675
+ "\n\n");
676
+ }
669
677
  if (!shown.length) {
670
678
  process.stdout.write("Everything is up to date.\n");
671
679
  }
package/dist/lib.d.ts CHANGED
@@ -8,8 +8,8 @@ export { listModules, addModule } from "./add";
8
8
  export type { ModuleManifest, ModuleMigration, ModulePathMove, ModuleTextReplacement, AddOptions, AddResult, } from "./add";
9
9
  export { status, diff, doctor, SUPPORTED_FRAMEWORKS } from "./inspect";
10
10
  export type { StatusReport, DoctorFinding } from "./inspect";
11
- export { planUpdate, summarize } from "./update";
12
- export type { UpdatePlan, FileChange } from "./update";
11
+ export { addedModuleReason, planUpdate, summarize } from "./update";
12
+ export type { AddedModule, UpdatePlan, FileChange } from "./update";
13
13
  export { readDevConfig, renderRoute, renderRuntimeCompose, resolveDevRuntime, runDevCommand } from "./dev";
14
14
  export type { CommandRunner, DevConfig, DevRuntime } from "./dev";
15
15
  export { applyDeployment, doctorDeployment, getDeploymentStatus, inspectClusterFingerprint, planDeployment, planRollback, rollbackDeployment, verifyDeployment, } from "./deploy";
package/dist/lib.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getComposeStatus = exports.doctorComposeDeployment = exports.applyComposeDeployment = exports.renderComposeDeployment = exports.parseComposeProfile = exports.initializeComposeProfile = exports.readDeploymentDriver = exports.loadKubernetesProfile = exports.loadComposeProfile = exports.loadAnyDeploymentProfile = exports.listAnyDeploymentProfiles = exports.assertAnyReleaseTag = exports.DEPLOY_DRIVERS = exports.renderDeployment = exports.loadDeploymentProfile = exports.listDeploymentProfiles = exports.initializeDeploymentProfile = exports.deploymentProfileDigest = exports.assertReleaseTag = exports.verifyDeployment = exports.rollbackDeployment = exports.planRollback = exports.planDeployment = exports.inspectClusterFingerprint = exports.getDeploymentStatus = exports.doctorDeployment = exports.applyDeployment = exports.runDevCommand = exports.resolveDevRuntime = exports.renderRuntimeCompose = exports.renderRoute = exports.readDevConfig = exports.summarize = exports.planUpdate = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.PROVIDERS_SOURCE = exports.providerSource = exports.planProviderChange = exports.listProviders = exports.applyProviderChange = exports.create = void 0;
4
- exports.verifyAnyDeployment = exports.planAnyDeployment = exports.getAnyDeploymentStatus = exports.doctorAnyDeployment = exports.describeDeploymentTarget = exports.verifyComposeDeployment = exports.rollbackComposeDeployment = exports.planComposeRollback = exports.planComposeDeployment = exports.inspectComposeEndpointFingerprint = void 0;
3
+ exports.doctorComposeDeployment = exports.applyComposeDeployment = exports.renderComposeDeployment = exports.parseComposeProfile = exports.initializeComposeProfile = exports.readDeploymentDriver = exports.loadKubernetesProfile = exports.loadComposeProfile = exports.loadAnyDeploymentProfile = exports.listAnyDeploymentProfiles = exports.assertAnyReleaseTag = exports.DEPLOY_DRIVERS = exports.renderDeployment = exports.loadDeploymentProfile = exports.listDeploymentProfiles = exports.initializeDeploymentProfile = exports.deploymentProfileDigest = exports.assertReleaseTag = exports.verifyDeployment = exports.rollbackDeployment = exports.planRollback = exports.planDeployment = exports.inspectClusterFingerprint = exports.getDeploymentStatus = exports.doctorDeployment = exports.applyDeployment = exports.runDevCommand = exports.resolveDevRuntime = exports.renderRuntimeCompose = exports.renderRoute = exports.readDevConfig = exports.summarize = exports.planUpdate = exports.addedModuleReason = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.PROVIDERS_SOURCE = exports.providerSource = exports.planProviderChange = exports.listProviders = exports.applyProviderChange = exports.create = void 0;
4
+ exports.verifyAnyDeployment = exports.planAnyDeployment = exports.getAnyDeploymentStatus = exports.doctorAnyDeployment = exports.describeDeploymentTarget = exports.verifyComposeDeployment = exports.rollbackComposeDeployment = exports.planComposeRollback = exports.planComposeDeployment = exports.inspectComposeEndpointFingerprint = exports.getComposeStatus = void 0;
5
5
  exports.builtinTemplatesDir = builtinTemplatesDir;
6
6
  exports.builtinModulesDir = builtinModulesDir;
7
7
  // Library entry point: the reusable functions behind the `podo` CLI, for other
@@ -29,6 +29,7 @@ Object.defineProperty(exports, "diff", { enumerable: true, get: function () { re
29
29
  Object.defineProperty(exports, "doctor", { enumerable: true, get: function () { return inspect_1.doctor; } });
30
30
  Object.defineProperty(exports, "SUPPORTED_FRAMEWORKS", { enumerable: true, get: function () { return inspect_1.SUPPORTED_FRAMEWORKS; } });
31
31
  var update_1 = require("./update");
32
+ Object.defineProperty(exports, "addedModuleReason", { enumerable: true, get: function () { return update_1.addedModuleReason; } });
32
33
  Object.defineProperty(exports, "planUpdate", { enumerable: true, get: function () { return update_1.planUpdate; } });
33
34
  Object.defineProperty(exports, "summarize", { enumerable: true, get: function () { return update_1.summarize; } });
34
35
  var dev_1 = require("./dev");
@@ -116,4 +116,6 @@ export declare function mergeOwnedGlobs(existing: string[], add: string[]): stri
116
116
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
117
117
  * survive every future recompute. No-op on projects without a manifest.
118
118
  */
119
- export declare function recordModules(projectRoot: string, moduleInputs: (string | ManifestModuleInput)[], version?: string, ownedGlobs?: string[], lockSnapshot?: RecordModulesLockSnapshot, managedOverrides?: string[]): void;
119
+ export declare function recordModules(projectRoot: string, moduleInputs: (string | ManifestModuleInput)[], version?: string, ownedGlobs?: string[], lockSnapshot?: RecordModulesLockSnapshot, managedOverrides?: string[],
120
+ /** Installed module that newly recorded modules must configure before. */
121
+ insertBefore?: string): void;
package/dist/lockfile.js CHANGED
@@ -257,29 +257,40 @@ function mergeOwnedGlobs(existing, add) {
257
257
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
258
258
  * survive every future recompute. No-op on projects without a manifest.
259
259
  */
260
- function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot, managedOverrides) {
260
+ function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot, managedOverrides,
261
+ /** Installed module that newly recorded modules must configure before. */
262
+ insertBefore) {
261
263
  const manifest = readManifest(projectRoot);
262
264
  if (!manifest)
263
265
  return;
264
266
  const stampedWith = version ?? podokitVersion();
265
- const known = new Set(manifest.modules.map((m) => m.name));
267
+ const fresh = [];
266
268
  for (const input of moduleInputs) {
267
269
  const module = typeof input === "string" ? { name: input } : input;
268
- const existing = manifest.modules.find((entry) => entry.name === module.name);
270
+ const existing = manifest.modules.find((entry) => entry.name === module.name)
271
+ ?? fresh.find((entry) => entry.name === module.name);
269
272
  if (existing) {
270
273
  existing.packageName = module.packageName ?? existing.packageName;
271
274
  existing.moduleVersion = module.moduleVersion ?? existing.moduleVersion;
272
275
  continue;
273
276
  }
274
- manifest.modules.push({
277
+ fresh.push({
275
278
  name: module.name,
276
- order: manifest.modules.length,
279
+ order: manifest.modules.length + fresh.length,
277
280
  addedWith: stampedWith,
278
281
  packageName: module.packageName,
279
282
  moduleVersion: module.moduleVersion,
280
283
  appliedMigrations: module.appliedMigrations,
281
284
  });
282
- known.add(module.name);
285
+ }
286
+ const ordered = [...manifest.modules].sort((a, b) => a.order - b.order);
287
+ const position = insertBefore ? ordered.findIndex((entry) => entry.name === insertBefore) : -1;
288
+ if (position === -1) {
289
+ manifest.modules.push(...fresh);
290
+ }
291
+ else {
292
+ ordered.splice(position, 0, ...fresh);
293
+ manifest.modules = ordered.map((entry, order) => ({ ...entry, order }));
283
294
  }
284
295
  if (ownedGlobs?.length)
285
296
  manifest.ownedGlobs = mergeOwnedGlobs(manifest.ownedGlobs, ownedGlobs);
@@ -5,6 +5,7 @@ const targets = [
5
5
  { entrypoint: "src/database/migrate.ts", outdir: "dist/database" },
6
6
  { entrypoint: "src/migrate.ts", outdir: "dist" },
7
7
  { entrypoint: "src/main-worker.ts", outdir: "dist" },
8
+ { entrypoint: "src/bootstrap-admin-runtime.ts", outdir: "dist" },
8
9
  ].filter((target) => existsSync(target.entrypoint));
9
10
 
10
11
  for (const target of targets) {
@@ -37,6 +37,7 @@ const MODULE_ROUTES: Record<string, string[]> = {
37
37
  "GET /profile-images/{fileName}",
38
38
  "GET /admin/sessions",
39
39
  "DELETE /admin/sessions/{id}",
40
+ "GET /admin/users",
40
41
  ],
41
42
  "api-key-auth": ["GET /machine/ping"],
42
43
  "audit-log": ["GET /audit-logs", "GET /audit-logs/actions"],
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import type { DatabaseProviderName } from "@podosoft/podokit-runtime";
3
+ import { PROVIDERS } from "../config/providers";
4
+
5
+ function databaseProvider(): DatabaseProviderName {
6
+ return PROVIDERS.database;
7
+ }
8
+
9
+ // SQLite applications migrate with the Bun SQLite migrator, not this TypeORM source.
10
+ describe.skipIf(databaseProvider() !== "postgres")("migration data source", () => {
11
+ it("runs each migration in its own transaction so one can build indexes concurrently", async () => {
12
+ const { dataSourceOptions } = await import("./data-source.js");
13
+ expect(dataSourceOptions.migrationsTransactionMode).toBe("each");
14
+ });
15
+ });
@@ -15,6 +15,9 @@ const migrations = existsSync(compiledMigrations)
15
15
  const commonOptions = {
16
16
  entities: [],
17
17
  migrations,
18
+ // One transaction per migration, so a migration can set `transaction = false`
19
+ // for statements such as CREATE INDEX CONCURRENTLY.
20
+ migrationsTransactionMode: "each" as const,
18
21
  synchronize: false,
19
22
  };
20
23
 
@@ -25,7 +25,7 @@ async function migrate(): Promise<void> {
25
25
  await dataSource.initialize();
26
26
  try {
27
27
  if (process.argv.includes("--revert")) await dataSource.undoLastMigration({ transaction: "all" });
28
- else await dataSource.runMigrations({ transaction: "all" });
28
+ else await dataSource.runMigrations();
29
29
  } finally {
30
30
  await dataSource.destroy();
31
31
  }
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "@internationalized/date": "^3.12.2",
18
18
  "@lucide/svelte": "^1.23.0",
19
- "@podosoft/podokit-api-client": "^0.8.0",
19
+ "@podosoft/podokit-api-client": "^0.8.5",
20
20
  "bits-ui": "^2.18.1",
21
21
  "clsx": "^2.1.1",
22
22
  "mode-watcher": "^1.1.0",
@@ -19,5 +19,12 @@ description: Use when adding or changing a SvelteKit page/route in apps/web —
19
19
  skill, added with the admin-dashboard module).
20
20
  5. **i18n**: no hardcoded user-facing strings — add keys to
21
21
  `apps/web/src/lib/i18n/messages.ts` for **every** locale.
22
- 6. **Verify**: `{{webRun}} build` (includes `svelte-check`)
22
+ 6. **Public pages** (with the admin-dashboard module): every page outside the
23
+ sign-in flow, `/`, and module pages requires a session and answers 503 while
24
+ the API is unavailable. Add the prefix of a page anonymous visitors should see
25
+ (marketing, pricing, legal, `robots.txt`) to `APP_PUBLIC_PATHS` in the
26
+ app-owned `apps/web/src/lib/server/public-paths.ts`. A prefix covers its
27
+ subpaths. Give such a page's loader a fallback for when the API is down
28
+ instead of throwing. Never edit `PUBLIC_PATHS` in the managed `guards.ts`.
29
+ 7. **Verify**: `{{webRun}} build` (includes `svelte-check`)
23
30
  and add/adjust a Playwright test in `tests/`.
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { join } from "node:path";
4
- import type { TestConfig } from "@playwright/test";
4
+ import type { PlaywrightTestConfig } from "@playwright/test";
5
5
 
6
- type Projects = NonNullable<TestConfig["projects"]>;
6
+ type Projects = NonNullable<PlaywrightTestConfig["projects"]>;
7
7
 
8
8
  const requireFromTests = createRequire(__filename);
9
9
 
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import { existsSync } from "node:fs";
3
4
  import { pathToFileURL } from "node:url";
4
5
 
6
+ const compiledRuntime = new URL("../dist/bootstrap-admin-runtime.js", import.meta.url);
7
+ const sourceRuntime = new URL("../src/bootstrap-admin-runtime.ts", import.meta.url);
8
+
5
9
  const args = process.argv.slice(2);
6
10
 
7
11
  function has(flag) {
@@ -130,34 +134,15 @@ async function main() {
130
134
  }
131
135
 
132
136
  const input = resolveBootstrapInput();
133
- // Load the auth graph before importing one of its dependencies directly.
134
- // Concurrent ESM imports can ask Node to link the same Better Auth submodule
135
- // through two graph roots and fail before the command reaches the database.
136
- const { auth } = await import("../dist/auth/auth.js");
137
- const { pool } = await import("../dist/auth/db.js");
138
- const { verifyPassword } = await import("better-auth/crypto");
137
+ // A production image runs the bundled entry; development runs the source.
138
+ const useCompiled = process.env.NODE_ENV === "production" && existsSync(compiledRuntime);
139
+ const runtime = await import((useCompiled ? compiledRuntime : sourceRuntime).href);
140
+ const { auth, findBootstrapUser, verifyPassword, closeAuthDatabase } = runtime;
139
141
 
140
142
  try {
141
143
  const result = await bootstrapAdmin(
142
144
  {
143
- findUser: async (email) => {
144
- const query = await pool.query(
145
- `SELECT u.id,
146
- u.email,
147
- u.role,
148
- u."emailVerified" AS "emailVerified",
149
- u."signupApproved" AS "signupApproved",
150
- a.password AS "passwordHash"
151
- FROM "user" AS u
152
- LEFT JOIN "account" AS a
153
- ON a."userId" = u.id
154
- AND a."providerId" = 'credential'
155
- WHERE lower(u.email) = lower($1)
156
- LIMIT 1`,
157
- [email],
158
- );
159
- return query.rows[0] ?? null;
160
- },
145
+ findUser: findBootstrapUser,
161
146
  createUser: async (user) => auth.api.createUser({ body: user }),
162
147
  verifyPassword: async (hash, password) => verifyPassword({ hash, password }),
163
148
  },
@@ -174,14 +159,24 @@ async function main() {
174
159
  console.log(`Verified existing administrator account ${result.email}.`);
175
160
  }
176
161
  } finally {
177
- await pool.end();
162
+ await closeAuthDatabase();
163
+ }
164
+ }
165
+
166
+ // A refused PostgreSQL connection is an AggregateError with an empty message.
167
+ export function errorMessage(error) {
168
+ if (!(error instanceof Error)) return String(error);
169
+ if (error.message) return error.message;
170
+ if (error instanceof AggregateError && error.errors.length > 0) {
171
+ return error.errors.map(errorMessage).join("; ");
178
172
  }
173
+ return error.name;
179
174
  }
180
175
 
181
176
  const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
182
177
  if (invokedPath === import.meta.url) {
183
178
  main().catch((error) => {
184
- console.error(error instanceof Error ? error.message : String(error));
179
+ console.error(errorMessage(error));
185
180
  process.exitCode = 1;
186
181
  });
187
182
  }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import type { QueryRunner } from "typeorm";
4
+ import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
5
+ import { IndexSessionCreatedAt1792060000000 } from "../migrations/1792060000000-IndexSessionCreatedAt";
6
+
7
+ function runner(type: "postgres" | "better-sqlite3", queries: string[], invalid = false): QueryRunner {
8
+ return {
9
+ connection: { options: { type } },
10
+ query: (sql: string): Promise<unknown[]> => {
11
+ queries.push(sql);
12
+ return Promise.resolve(invalid && sql.includes("indisvalid") ? [{ invalid: 1 }] : []);
13
+ },
14
+ } as unknown as QueryRunner;
15
+ }
16
+
17
+ // Finalized at once: an open statement keeps SQLite from dropping an index.
18
+ function rows<Row>(database: Database, sql: string): Row[] {
19
+ const statement = database.prepare<Row, []>(sql);
20
+ try {
21
+ return statement.all();
22
+ } finally {
23
+ statement.finalize();
24
+ }
25
+ }
26
+
27
+ const CREATE = 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_session_createdAt_id" ON "session" ("createdAt", "id")';
28
+
29
+ describe("admin session list index migration", () => {
30
+ it("builds the PostgreSQL index concurrently outside a transaction", async () => {
31
+ const migration = new IndexSessionCreatedAt1792060000000();
32
+ expect(migration.transaction).toBe(false);
33
+ const up: string[] = [];
34
+ await migration.up(runner("postgres", up));
35
+ expect(up).toEqual([
36
+ "SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid",
37
+ CREATE,
38
+ ]);
39
+
40
+ const down: string[] = [];
41
+ await migration.down(runner("postgres", down));
42
+ expect(down).toEqual(['DROP INDEX IF EXISTS "IDX_session_createdAt_id"']);
43
+ });
44
+
45
+ it("rebuilds an invalid index left by an interrupted build", async () => {
46
+ const up: string[] = [];
47
+ await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, true));
48
+ expect(up.slice(1)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
49
+ });
50
+
51
+ it("serves the newest-first session page on SQLite and reverts", async () => {
52
+ const database = new Database(":memory:");
53
+ try {
54
+ database.run(`CREATE TABLE "session" (
55
+ "id" text PRIMARY KEY, "userId" text NOT NULL, "token" text NOT NULL,
56
+ "expiresAt" date NOT NULL, "createdAt" date NOT NULL, "updatedAt" date NOT NULL
57
+ )`);
58
+ const migrations = [{ timestamp: 1792060000000, migration: new IndexSessionCreatedAt1792060000000() }];
59
+ await runSqliteMigrations(database, migrations);
60
+ const plan = rows<{ detail: string }>(
61
+ database,
62
+ `EXPLAIN QUERY PLAN SELECT "id" FROM "session" WHERE "expiresAt" > '2026' ORDER BY "createdAt" DESC, "id" DESC LIMIT 16`,
63
+ ).map((row) => row.detail).join("\n");
64
+ expect(plan).toContain("IDX_session_createdAt_id");
65
+ expect(plan).not.toContain("TEMP B-TREE");
66
+
67
+ expect(await revertLatestSqliteMigration(database, migrations)).toBe(true);
68
+ expect(rows(database, `SELECT "name" FROM "sqlite_master" WHERE "type" = 'index' AND "name" LIKE 'IDX_%'`))
69
+ .toEqual([]);
70
+ } finally {
71
+ database.close();
72
+ }
73
+ });
74
+ });