@podosoft/podokit 0.12.4 → 0.14.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 (101) hide show
  1. package/dist/add.d.ts +27 -1
  2. package/dist/add.js +3 -1
  3. package/dist/index.js +6 -3
  4. package/dist/lib.d.ts +1 -1
  5. package/dist/lockfile.d.ts +4 -1
  6. package/dist/lockfile.js +4 -3
  7. package/dist/module-migrations.d.ts +25 -0
  8. package/dist/module-migrations.js +233 -0
  9. package/dist/templates/fullstack-nest-svelte/Dockerfile.dev +3 -1
  10. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +1 -1
  11. package/dist/templates/fullstack-nest-svelte/apps/web/Dockerfile +3 -0
  12. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +1 -1
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +3 -1
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +5 -0
  15. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +6 -0
  16. package/dist/templates/fullstack-nest-svelte/dot-env.example +2 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/configmap.yaml +1 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image-upload.interceptor.ts +39 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.controller.ts +73 -0
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.module.ts +9 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +43 -0
  22. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +110 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.spec.ts +79 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.validation.ts +74 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +1 -1
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +155 -2
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +109 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +5 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +21 -1
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +21 -1
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +78 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/+layout.server.ts +8 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/+layout.svelte +2 -2
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.server.ts +8 -1
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/+page.svelte +73 -2
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +2 -47
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.server.ts +3 -2
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +5 -0
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +15 -1
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+page.svelte +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +44 -4
  43. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +10 -10
  44. package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +3 -3
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +96 -0
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -2
  47. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +3 -3
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +70 -7
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +1 -1
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +18 -2
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +1 -1
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +3 -3
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -9
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +1 -1
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -2
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +1 -1
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +2 -2
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +2 -2
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +36 -3
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +77 -7
  63. package/dist/templates/modules/admin-dashboard/files/tests/ui/sidebar.ui.spec.ts +1 -1
  64. package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +1 -1
  65. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +3 -3
  66. package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +1 -1
  67. package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +4 -4
  68. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +1 -1
  69. package/dist/templates/modules/admin-dashboard/module.manifest.json +74 -11
  70. package/dist/templates/modules/audit-log/module.manifest.json +1 -1
  71. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +3 -6
  72. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +6 -0
  73. package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +12 -5
  74. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +9 -2
  75. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.spec.ts +42 -0
  76. package/dist/templates/modules/auth/files/apps/api/src/auth/user-delete-handlers.ts +27 -0
  77. package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +55 -3
  78. package/dist/templates/modules/auth/module.manifest.json +5 -4
  79. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +1 -1
  80. package/dist/templates/modules/mailer/module.manifest.json +1 -1
  81. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
  82. package/dist/templates/todo/Dockerfile.dev +3 -1
  83. package/dist/templates/todo/apps/api/package.json +1 -1
  84. package/dist/templates/todo/apps/web/package.json +1 -1
  85. package/dist/templates/todo/compose.dev.yaml +6 -0
  86. package/dist/update.d.ts +8 -1
  87. package/dist/update.js +68 -12
  88. package/package.json +1 -1
  89. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/{app-sidebar.svelte → admin-sidebar.svelte} +0 -0
  90. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/+page.svelte +0 -0
  91. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.server.ts +0 -0
  92. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/account/+page.svelte +0 -0
  93. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.server.ts +0 -0
  94. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/audit/+page.svelte +0 -0
  95. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.server.ts +0 -0
  96. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/organizations/+page.svelte +0 -0
  97. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.server.ts +0 -0
  98. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/sessions/+page.svelte +0 -0
  99. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/settings/general-settings.svelte +0 -0
  100. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.server.ts +0 -0
  101. /package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/{(app) → (admin)}/admin/users/+page.svelte +0 -0
package/dist/add.d.ts CHANGED
@@ -7,7 +7,31 @@ interface Injection {
7
7
  }
8
8
  /** Highest `module.manifest.json` schema version this CLI understands. A package
9
9
  * module declaring a higher version is rejected rather than mis-applied. */
10
- export declare const SUPPORTED_MANIFEST_VERSION = 1;
10
+ export declare const SUPPORTED_MANIFEST_VERSION = 2;
11
+ export interface ModulePathMove {
12
+ /** Project-relative file or directory path from an older module layout. */
13
+ from: string;
14
+ /** Project-relative destination. Existing files are never overwritten. */
15
+ to: string;
16
+ }
17
+ export interface ModuleTextReplacement {
18
+ /** Destination path after all migration moves have been applied. */
19
+ path: string;
20
+ /** Exact text expected in the existing generated file. */
21
+ from: string;
22
+ /** Replacement text written while preserving every other local edit. */
23
+ to: string;
24
+ /** Required number of matches. A mismatch aborts the migration atomically. */
25
+ expected: number;
26
+ }
27
+ export interface ModuleMigration {
28
+ /** Stable module-local identifier recorded after a successful apply. */
29
+ id: string;
30
+ /** File or directory moves performed as one atomic migration. */
31
+ moves: ModulePathMove[];
32
+ /** Optional exact rewrites needed after a move, such as renamed imports. */
33
+ replacements?: ModuleTextReplacement[];
34
+ }
11
35
  export interface ModuleManifest {
12
36
  /** Manifest schema version. Absent (bundled modules) means "same as the CLI". */
13
37
  manifestVersion?: number;
@@ -35,6 +59,8 @@ export interface ModuleManifest {
35
59
  /** Module files that remain update-managed even when a broad default glob
36
60
  * (for example `.claude/**`) would otherwise classify them as owned. */
37
61
  managedOverrides?: string[];
62
+ /** Idempotent path migrations applied by `podo update --apply`. */
63
+ migrations?: ModuleMigration[];
38
64
  }
39
65
  export interface PackageOverlay {
40
66
  dependencies?: Record<string, string>;
package/dist/add.js CHANGED
@@ -14,7 +14,7 @@ const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
14
14
  const lockfile_1 = require("./lockfile");
15
15
  /** Highest `module.manifest.json` schema version this CLI understands. A package
16
16
  * module declaring a higher version is rejected rather than mis-applied. */
17
- exports.SUPPORTED_MANIFEST_VERSION = 1;
17
+ exports.SUPPORTED_MANIFEST_VERSION = 2;
18
18
  /** Return the legacy targetApp package fields plus any additional app overlays. */
19
19
  function modulePackageOverlays(manifest) {
20
20
  const overlays = new Map();
@@ -281,10 +281,12 @@ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
281
281
  };
282
282
  }
283
283
  function moduleRecord(module) {
284
+ const migrations = readModuleManifest(module.dir).migrations ?? [];
284
285
  return {
285
286
  name: module.name,
286
287
  packageName: module.packageName,
287
288
  moduleVersion: module.moduleVersion,
289
+ appliedMigrations: migrations.map((migration) => migration.id),
288
290
  };
289
291
  }
290
292
  /** Copy a module overlay without clobbering app-owned presentation files. */
package/dist/index.js CHANGED
@@ -288,7 +288,8 @@ async function main(argv) {
288
288
  try {
289
289
  if (args.apply) {
290
290
  const result = (0, update_1.applyUpdate)(process.cwd(), templatesDir, { oldTemplatesDir: args.from });
291
- process.stdout.write(`Applied: ${result.written.length} written, ${result.removed.length} removed, ` +
291
+ process.stdout.write(`Applied: ${result.written.length} written, ${result.moved.length} moved, ` +
292
+ `${result.removed.length} removed, ` +
292
293
  `${result.merged.length} merged, ${result.conflicts.length} conflict.\n`);
293
294
  if (result.conflicts.length) {
294
295
  process.stdout.write("\nResolve the following, then commit:\n" +
@@ -306,9 +307,11 @@ async function main(argv) {
306
307
  }
307
308
  else {
308
309
  for (const c of shown) {
309
- process.stdout.write(` ${c.action.padEnd(9)} ${c.path} (${c.note})\n`);
310
+ const path = c.fromPath ? `${c.fromPath} -> ${c.path}` : c.path;
311
+ process.stdout.write(` ${c.action.padEnd(9)} ${path} (${c.note})\n`);
310
312
  }
311
- process.stdout.write(`\n${counts.update} update, ${counts.add} add, ${counts.remove} remove, ${counts.conflict} conflict. ` +
313
+ process.stdout.write(`\n${counts.update} update, ${counts.add} add, ${counts.move} move, ` +
314
+ `${counts.remove} remove, ${counts.conflict} conflict. ` +
312
315
  `Dry-run — nothing was written. Re-run with --apply to write (use --from <dir> for a 3-way merge).\n`);
313
316
  }
314
317
  }
package/dist/lib.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { CreateOptions, CreateResult } from "./create";
3
3
  export { TEMPLATES, DEFAULT_TEMPLATE, TEMPLATE_NAMES, isKnownTemplate } from "./templates";
4
4
  export type { TemplateInfo } from "./templates";
5
5
  export { listModules, addModule } from "./add";
6
- export type { ModuleManifest, AddOptions, AddResult } from "./add";
6
+ export type { ModuleManifest, ModuleMigration, ModulePathMove, ModuleTextReplacement, AddOptions, AddResult, } from "./add";
7
7
  export { status, diff, doctor, SUPPORTED_FRAMEWORKS } from "./inspect";
8
8
  export type { StatusReport, DoctorFinding } from "./inspect";
9
9
  export { planUpdate, summarize } from "./update";
@@ -3,7 +3,7 @@
3
3
  * `podo update` can rebuild the same inputs and detect user edits. See
4
4
  * ADR-0009 (update mechanism) and ADR-0010 (ownership model).
5
5
  */
6
- export declare const LOCK_SCHEMA_VERSION = 1;
6
+ export declare const LOCK_SCHEMA_VERSION = 2;
7
7
  /** File ownership tier. See ADR-0010. */
8
8
  export type Tier = "managed" | "assembled" | "owned";
9
9
  export interface ManifestModule {
@@ -16,11 +16,14 @@ export interface ManifestModule {
16
16
  packageName?: string;
17
17
  /** Installed external-module version last applied to this project. */
18
18
  moduleVersion?: string;
19
+ /** Module-local update migrations already applied to this project. */
20
+ appliedMigrations?: string[];
19
21
  }
20
22
  export interface ManifestModuleInput {
21
23
  name: string;
22
24
  packageName?: string;
23
25
  moduleVersion?: string;
26
+ appliedMigrations?: string[];
24
27
  }
25
28
  export interface PodokitManifest {
26
29
  schemaVersion: number;
package/dist/lockfile.js CHANGED
@@ -24,7 +24,7 @@ const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
24
24
  * `podo update` can rebuild the same inputs and detect user edits. See
25
25
  * ADR-0009 (update mechanism) and ADR-0010 (ownership model).
26
26
  */
27
- exports.LOCK_SCHEMA_VERSION = 1;
27
+ exports.LOCK_SCHEMA_VERSION = 2;
28
28
  const PODOKIT_DIR = ".podokit";
29
29
  const MANIFEST_FILE = "manifest.json";
30
30
  const FILES_LOCK_FILE = "files.lock";
@@ -83,11 +83,11 @@ function readManifest(projectRoot) {
83
83
  }
84
84
  function writeManifest(projectRoot, manifest) {
85
85
  (0, node_fs_1.mkdirSync)(podokitDir(projectRoot), { recursive: true });
86
- (0, node_fs_1.writeFileSync)(manifestPath(projectRoot), `${JSON.stringify(manifest, null, 2)}\n`);
86
+ (0, node_fs_1.writeFileSync)(manifestPath(projectRoot), `${JSON.stringify({ ...manifest, schemaVersion: exports.LOCK_SCHEMA_VERSION }, null, 2)}\n`);
87
87
  }
88
88
  function writeFilesLock(projectRoot, lock) {
89
89
  (0, node_fs_1.mkdirSync)(podokitDir(projectRoot), { recursive: true });
90
- (0, node_fs_1.writeFileSync)(filesLockPath(projectRoot), `${JSON.stringify(lock, null, 2)}\n`);
90
+ (0, node_fs_1.writeFileSync)(filesLockPath(projectRoot), `${JSON.stringify({ ...lock, schemaVersion: exports.LOCK_SCHEMA_VERSION }, null, 2)}\n`);
91
91
  }
92
92
  /** Read the PodoKit CLI's own version to stamp into the manifest. */
93
93
  function podokitVersion() {
@@ -242,6 +242,7 @@ function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnaps
242
242
  addedWith: stampedWith,
243
243
  packageName: module.packageName,
244
244
  moduleVersion: module.moduleVersion,
245
+ appliedMigrations: module.appliedMigrations,
245
246
  });
246
247
  known.add(module.name);
247
248
  }
@@ -0,0 +1,25 @@
1
+ import type { VfsTree } from "@podosoft/podokit-template-engine";
2
+ import { type ModuleMigration } from "./add";
3
+ import type { FilesLock, ManifestModule, Tier } from "./lockfile";
4
+ export interface PendingModuleMigration {
5
+ module: string;
6
+ migration: ModuleMigration;
7
+ }
8
+ export interface PlannedPathMove {
9
+ from: string;
10
+ to: string;
11
+ tier: Tier;
12
+ content: Buffer;
13
+ }
14
+ export interface ModuleMigrationPlan {
15
+ pending: PendingModuleMigration[];
16
+ moves: PlannedPathMove[];
17
+ conflicts: string[];
18
+ }
19
+ export declare function collectPendingMigrations(projectRoot: string, templatesDir: string, modules: ManifestModule[]): PendingModuleMigration[];
20
+ export declare function planModuleMigrations(projectRoot: string, targetTree: VfsTree, lock: FilesLock, pending: PendingModuleMigration[]): ModuleMigrationPlan;
21
+ export declare function applyModuleMigrations(projectRoot: string, plan: ModuleMigrationPlan): {
22
+ from: string;
23
+ to: string;
24
+ }[];
25
+ export declare function migrateConfiguredPath(path: string, pending: PendingModuleMigration[]): string;
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectPendingMigrations = collectPendingMigrations;
4
+ exports.planModuleMigrations = planModuleMigrations;
5
+ exports.applyModuleMigrations = applyModuleMigrations;
6
+ exports.migrateConfiguredPath = migrateConfiguredPath;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_crypto_1 = require("node:crypto");
9
+ const node_path_1 = require("node:path");
10
+ const add_1 = require("./add");
11
+ function treeBuffer(tree, path) {
12
+ const file = tree.get(path);
13
+ if (!file)
14
+ return null;
15
+ return Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content);
16
+ }
17
+ function assertProjectPath(path, label) {
18
+ if (!path ||
19
+ path.startsWith("/") ||
20
+ path.endsWith("/") ||
21
+ node_path_1.posix.normalize(path) !== path ||
22
+ path.split("/").includes("..")) {
23
+ throw new Error(`${label} must be a normalized project-relative path: ${path}`);
24
+ }
25
+ }
26
+ function filesUnder(projectRoot, path) {
27
+ const absolute = (0, node_path_1.join)(projectRoot, path);
28
+ if (!(0, node_fs_1.existsSync)(absolute))
29
+ return [];
30
+ if (!(0, node_fs_1.statSync)(absolute).isDirectory())
31
+ return [path];
32
+ const files = [];
33
+ const visit = (relative) => {
34
+ for (const entry of (0, node_fs_1.readdirSync)((0, node_path_1.join)(projectRoot, relative), { withFileTypes: true })) {
35
+ const child = `${relative}/${entry.name}`;
36
+ if (entry.isDirectory())
37
+ visit(child);
38
+ else
39
+ files.push(child);
40
+ }
41
+ };
42
+ visit(path);
43
+ return files.sort();
44
+ }
45
+ function destinationFor(path, from, to) {
46
+ return path === from ? to : `${to}${path.slice(from.length)}`;
47
+ }
48
+ function occurrences(content, needle) {
49
+ if (!needle)
50
+ return 0;
51
+ let count = 0;
52
+ let offset = 0;
53
+ while (true) {
54
+ const index = content.indexOf(needle, offset);
55
+ if (index === -1)
56
+ return count;
57
+ count += 1;
58
+ offset = index + needle.length;
59
+ }
60
+ }
61
+ function transformedContent(path, content, pending, conflicts) {
62
+ let text = null;
63
+ for (const { module, migration } of pending) {
64
+ for (const replacement of migration.replacements ?? []) {
65
+ if (replacement.path !== path)
66
+ continue;
67
+ text ??= content.toString("utf8");
68
+ const currentCount = occurrences(text, replacement.from);
69
+ if (currentCount === replacement.expected) {
70
+ text = text.split(replacement.from).join(replacement.to);
71
+ continue;
72
+ }
73
+ const replacementCount = occurrences(text, replacement.to);
74
+ if (currentCount === 0 && replacementCount === replacement.expected)
75
+ continue;
76
+ conflicts.push(`${module}:${migration.id} expected ${replacement.expected} occurrence(s) in ` +
77
+ `${replacement.path}, found ${currentCount}`);
78
+ }
79
+ }
80
+ return text === null ? content : Buffer.from(text);
81
+ }
82
+ function matchesPathOrChild(path, prefix) {
83
+ return path === prefix || path.startsWith(`${prefix}/`);
84
+ }
85
+ function collectPendingMigrations(projectRoot, templatesDir, modules) {
86
+ const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
87
+ const pending = [];
88
+ for (const module of modules) {
89
+ const resolved = (0, add_1.resolveModule)(module.packageName ?? module.name, modulesDir, projectRoot);
90
+ if (!resolved)
91
+ continue;
92
+ const manifest = (0, add_1.readModuleManifest)(resolved.dir);
93
+ const applied = new Set(module.appliedMigrations ?? []);
94
+ for (const migration of manifest.migrations ?? []) {
95
+ if (applied.has(migration.id))
96
+ continue;
97
+ if (!migration.id || migration.moves.length === 0) {
98
+ throw new Error(`Module "${module.name}" declares an invalid migration.`);
99
+ }
100
+ for (const move of migration.moves) {
101
+ assertProjectPath(move.from, `${module.name}:${migration.id} move.from`);
102
+ assertProjectPath(move.to, `${module.name}:${migration.id} move.to`);
103
+ if (move.from === move.to) {
104
+ throw new Error(`${module.name}:${migration.id} cannot move a path onto itself.`);
105
+ }
106
+ }
107
+ for (const replacement of migration.replacements ?? []) {
108
+ assertProjectPath(replacement.path, `${module.name}:${migration.id} replacement.path`);
109
+ if (!replacement.from || replacement.expected < 1) {
110
+ throw new Error(`${module.name}:${migration.id} declares an invalid text replacement.`);
111
+ }
112
+ }
113
+ pending.push({ module: module.name, migration });
114
+ }
115
+ }
116
+ return pending;
117
+ }
118
+ function planModuleMigrations(projectRoot, targetTree, lock, pending) {
119
+ const conflicts = [];
120
+ const moveSources = new Set();
121
+ const moveTargets = new Set();
122
+ const candidates = [];
123
+ for (const { module, migration } of pending) {
124
+ for (const move of migration.moves) {
125
+ const staleTargetPaths = [...targetTree.keys()].filter((path) => matchesPathOrChild(path, move.from));
126
+ if (staleTargetPaths.length) {
127
+ conflicts.push(`${module}:${migration.id} target modules still provide legacy path ${move.from}; ` +
128
+ `upgrade external modules before updating`);
129
+ continue;
130
+ }
131
+ for (const source of filesUnder(projectRoot, move.from)) {
132
+ const target = destinationFor(source, move.from, move.to);
133
+ if (moveSources.has(source)) {
134
+ conflicts.push(`${module}:${migration.id} moves ${source} more than once`);
135
+ continue;
136
+ }
137
+ if (moveTargets.has(target)) {
138
+ conflicts.push(`${module}:${migration.id} has duplicate destination ${target}`);
139
+ continue;
140
+ }
141
+ moveSources.add(source);
142
+ moveTargets.add(target);
143
+ candidates.push({
144
+ from: source,
145
+ to: target,
146
+ tier: lock.files[source]?.tier ?? "owned",
147
+ });
148
+ }
149
+ }
150
+ }
151
+ for (const candidate of candidates) {
152
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(projectRoot, candidate.to)) && !moveSources.has(candidate.to)) {
153
+ conflicts.push(`cannot move ${candidate.from}: destination already exists at ${candidate.to}`);
154
+ }
155
+ }
156
+ const moves = candidates.map((candidate) => {
157
+ const sourceContent = (0, node_fs_1.readFileSync)((0, node_path_1.join)(projectRoot, candidate.from));
158
+ const locked = lock.files[candidate.from];
159
+ const targetContent = treeBuffer(targetTree, candidate.to);
160
+ const content = locked && locked.outHash === hashBuffer(sourceContent) && targetContent
161
+ ? targetContent
162
+ : transformedContent(candidate.to, sourceContent, pending, conflicts);
163
+ return { ...candidate, content };
164
+ });
165
+ for (const { module, migration } of pending) {
166
+ for (const replacement of migration.replacements ?? []) {
167
+ if (moves.some((move) => move.to === replacement.path))
168
+ continue;
169
+ const target = (0, node_path_1.join)(projectRoot, replacement.path);
170
+ if (!(0, node_fs_1.existsSync)(target))
171
+ continue;
172
+ transformedContent(replacement.path, (0, node_fs_1.readFileSync)(target), [{ module, migration }], conflicts);
173
+ }
174
+ }
175
+ return { pending, moves, conflicts: [...new Set(conflicts)] };
176
+ }
177
+ function hashBuffer(content) {
178
+ return `sha256:${(0, node_crypto_1.createHash)("sha256").update(content).digest("hex")}`;
179
+ }
180
+ function pruneEmptyDirectories(projectRoot, relative) {
181
+ let current = relative;
182
+ while (current && current !== "." && current !== "/") {
183
+ try {
184
+ (0, node_fs_1.rmdirSync)((0, node_path_1.join)(projectRoot, current));
185
+ }
186
+ catch {
187
+ return;
188
+ }
189
+ current = (0, node_path_1.dirname)(current);
190
+ }
191
+ }
192
+ function applyModuleMigrations(projectRoot, plan) {
193
+ if (plan.conflicts.length)
194
+ return [];
195
+ const moved = [];
196
+ const replacementByPath = new Map();
197
+ for (const move of plan.moves)
198
+ replacementByPath.set(move.to, move.content);
199
+ for (const { module, migration } of plan.pending) {
200
+ for (const replacement of migration.replacements ?? []) {
201
+ if (replacementByPath.has(replacement.path))
202
+ continue;
203
+ const target = (0, node_path_1.join)(projectRoot, replacement.path);
204
+ if (!(0, node_fs_1.existsSync)(target))
205
+ continue;
206
+ replacementByPath.set(replacement.path, transformedContent(replacement.path, (0, node_fs_1.readFileSync)(target), [{ module, migration }], []));
207
+ }
208
+ }
209
+ for (const move of plan.moves) {
210
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)((0, node_path_1.join)(projectRoot, move.to)), { recursive: true });
211
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(projectRoot, move.to), replacementByPath.get(move.to) ?? move.content);
212
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(projectRoot, move.from));
213
+ pruneEmptyDirectories(projectRoot, (0, node_path_1.dirname)(move.from));
214
+ moved.push({ from: move.from, to: move.to });
215
+ }
216
+ for (const [path, content] of replacementByPath) {
217
+ if (plan.moves.some((move) => move.to === path))
218
+ continue;
219
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(projectRoot, path), content);
220
+ }
221
+ return moved;
222
+ }
223
+ function migrateConfiguredPath(path, pending) {
224
+ let migrated = path;
225
+ for (const { migration } of pending) {
226
+ for (const move of migration.moves) {
227
+ if (matchesPathOrChild(migrated, move.from)) {
228
+ migrated = destinationFor(migrated, move.from, move.to);
229
+ }
230
+ }
231
+ }
232
+ return migrated;
233
+ }
@@ -17,7 +17,9 @@ WORKDIR /app
17
17
  COPY package.json ./
18
18
  COPY apps/api/package.json ./apps/api/package.json
19
19
  COPY apps/web/package.json ./apps/web/package.json
20
- RUN npm install
20
+ ARG PODOKIT_NPM_REGISTRY=""
21
+ RUN if [ -n "$PODOKIT_NPM_REGISTRY" ]; then npm config set registry "$PODOKIT_NPM_REGISTRY"; fi \
22
+ && npm install
21
23
 
22
24
  # Baked source (kept in sync during development by `docker compose watch`).
23
25
  COPY . .
@@ -14,7 +14,7 @@
14
14
  "migration:generate": "npm run typeorm -- migration:generate"
15
15
  },
16
16
  "dependencies": {
17
- "@podosoft/podokit-contracts": "^0.2.0",
17
+ "@podosoft/podokit-contracts": "^0.4.0",
18
18
  "@nestjs/common": "^11.1.27",
19
19
  "@nestjs/config": "^4.0.4",
20
20
  "@nestjs/core": "^11.1.27",
@@ -16,6 +16,9 @@ RUN npm run build --workspace=apps/web
16
16
  FROM node:22-alpine AS runtime
17
17
  WORKDIR /app
18
18
  ENV NODE_ENV=production
19
+ # Keep the adapter-node proxy above the 2 MiB application upload policy so the
20
+ # API can return stable validation codes after accounting for multipart framing.
21
+ ENV BODY_SIZE_LIMIT=3M
19
22
  RUN addgroup -S app && adduser -S app -G app
20
23
  COPY --from=build /app/node_modules ./node_modules
21
24
  COPY --from=deps /app/apps/web ./apps/web
@@ -13,7 +13,7 @@
13
13
  "dependencies": {
14
14
  "@internationalized/date": "^3.12.2",
15
15
  "@lucide/svelte": "^1.23.0",
16
- "@podosoft/podokit-api-client": "^0.6.0",
16
+ "@podosoft/podokit-api-client": "^0.8.0",
17
17
  "bits-ui": "^2.18.1",
18
18
  "clsx": "^2.1.1",
19
19
  "mode-watcher": "^1.1.0",
@@ -16,6 +16,7 @@
16
16
  files = $bindable(),
17
17
  class: className,
18
18
  "data-slot": dataSlot = "input",
19
+ onchange,
19
20
  ...restProps
20
21
  }: Props = $props();
21
22
  </script>
@@ -30,7 +31,7 @@
30
31
  )}
31
32
  type="file"
32
33
  bind:files
33
- bind:value
34
+ {onchange}
34
35
  {...restProps}
35
36
  />
36
37
  {:else}
@@ -43,6 +44,7 @@
43
44
  )}
44
45
  {type}
45
46
  bind:value
47
+ {onchange}
46
48
  {...restProps}
47
49
  />
48
50
  {/if}
@@ -3,6 +3,8 @@
3
3
  import * as Card from "$lib/components/ui/card";
4
4
  import { api } from "$lib/api";
5
5
  import { ApiError } from "@podosoft/podokit-api-client";
6
+ // podokit:begin:landing-imports
7
+ // podokit:end:landing-imports
6
8
 
7
9
  type Health = { status: string } | { error: string };
8
10
 
@@ -17,6 +19,9 @@
17
19
  }
18
20
  </script>
19
21
 
22
+ <!-- podokit:begin:landing-actions -->
23
+ <!-- podokit:end:landing-actions -->
24
+
20
25
  <main class="mx-auto flex min-h-full max-w-2xl flex-col gap-6 p-8">
21
26
  <div>
22
27
  <h1 class="text-3xl font-bold">{{projectName}}</h1>
@@ -41,6 +41,8 @@ services:
41
41
  build:
42
42
  context: .
43
43
  dockerfile: Dockerfile.dev
44
+ args:
45
+ PODOKIT_NPM_REGISTRY: ${PODOKIT_NPM_REGISTRY:-}
44
46
  command: npm run dev -w {{projectName}}-web -- --host 0.0.0.0
45
47
  working_dir: /app
46
48
  env_file: .env.docker
@@ -64,6 +66,8 @@ services:
64
66
  build:
65
67
  context: .
66
68
  dockerfile: Dockerfile.dev
69
+ args:
70
+ PODOKIT_NPM_REGISTRY: ${PODOKIT_NPM_REGISTRY:-}
67
71
  init: true
68
72
  command: npm run dev -w {{projectName}}-api
69
73
  working_dir: /app
@@ -87,6 +91,8 @@ services:
87
91
  build:
88
92
  context: .
89
93
  dockerfile: Dockerfile.dev
94
+ args:
95
+ PODOKIT_NPM_REGISTRY: ${PODOKIT_NPM_REGISTRY:-}
90
96
  init: true
91
97
  command: npm run dev:worker -w {{projectName}}-api
92
98
  working_dir: /app
@@ -13,6 +13,8 @@ POSTGRES_DB=podokit
13
13
  # Web -> API boundary (server-side only; not exposed to the browser)
14
14
  BACKEND_INTERNAL_URL=http://localhost:5002
15
15
  CORS_ORIGIN=http://localhost:5001
16
+ # adapter-node request limit; leave room for a 2 MiB multipart upload
17
+ BODY_SIZE_LIMIT=3M
16
18
 
17
19
  # ── Optional / development ───────────────────────────────────────────
18
20
  # Redis — only needed by cache/queue modules (redis, bullmq, rate-limit,
@@ -8,5 +8,6 @@ data:
8
8
  PORT: "3000"
9
9
  CORS_ORIGIN: "https://example.com"
10
10
  BACKEND_INTERNAL_URL: "http://api:3000"
11
+ BODY_SIZE_LIMIT: "3M"
11
12
  ADDRESS_HEADER: "x-forwarded-for"
12
13
  XFF_DEPTH: "1"
@@ -0,0 +1,39 @@
1
+ import {
2
+ type CallHandler,
3
+ type ExecutionContext,
4
+ Injectable,
5
+ PayloadTooLargeException,
6
+ } from "@nestjs/common";
7
+ import { FileInterceptor } from "@nestjs/platform-express";
8
+ import {
9
+ AppException,
10
+ PROFILE_IMAGE_POLICY,
11
+ PROFILE_IMAGE_TOO_LARGE,
12
+ } from "@podosoft/podokit-contracts";
13
+ import type { Observable } from "rxjs";
14
+
15
+ const ProfileImageFileInterceptor = FileInterceptor("file", {
16
+ limits: { fileSize: PROFILE_IMAGE_POLICY.maxBytes },
17
+ });
18
+
19
+ /** Preserve a stable error code when Multer rejects an oversized request. */
20
+ @Injectable()
21
+ export class ProfileImageUploadInterceptor extends ProfileImageFileInterceptor {
22
+ override async intercept(
23
+ context: ExecutionContext,
24
+ next: CallHandler,
25
+ ): Promise<Observable<unknown>> {
26
+ try {
27
+ return await super.intercept(context, next);
28
+ } catch (error: unknown) {
29
+ if (error instanceof PayloadTooLargeException) {
30
+ throw new AppException(
31
+ PROFILE_IMAGE_TOO_LARGE,
32
+ "Profile image must be 2 MB or smaller.",
33
+ 413,
34
+ );
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,73 @@
1
+ /// <reference types="multer" />
2
+ import {
3
+ Controller,
4
+ Delete,
5
+ Get,
6
+ Param,
7
+ Post,
8
+ Req,
9
+ Res,
10
+ StreamableFile,
11
+ UploadedFile,
12
+ UseInterceptors,
13
+ } from "@nestjs/common";
14
+ import { ApiConsumes, ApiTags } from "@nestjs/swagger";
15
+ import { Public, Session, type UserSession } from "@thallesp/nestjs-better-auth";
16
+ import {
17
+ AppException,
18
+ PROFILE_IMAGE_REQUIRED,
19
+ type ProfileImageResponse,
20
+ } from "@podosoft/podokit-contracts";
21
+ import { fromNodeHeaders } from "better-auth/node";
22
+ import type { Request, Response } from "express";
23
+ import { ProfileImageService } from "./profile-image.service";
24
+ import { ProfileImageUploadInterceptor } from "./profile-image-upload.interceptor";
25
+
26
+ @ApiTags("account")
27
+ @Controller()
28
+ export class ProfileImageController {
29
+ constructor(private readonly profileImages: ProfileImageService) {}
30
+
31
+ @Post("account/profile-image")
32
+ @ApiConsumes("multipart/form-data")
33
+ @UseInterceptors(ProfileImageUploadInterceptor)
34
+ async upload(
35
+ @Session() session: UserSession,
36
+ @Req() req: Request,
37
+ @UploadedFile() file?: Express.Multer.File,
38
+ ): Promise<ProfileImageResponse> {
39
+ if (!file) {
40
+ throw new AppException(
41
+ PROFILE_IMAGE_REQUIRED,
42
+ "A profile image file is required.",
43
+ );
44
+ }
45
+ return this.profileImages.upload(
46
+ session.user.image ?? null,
47
+ file,
48
+ fromNodeHeaders(req.headers),
49
+ );
50
+ }
51
+
52
+ @Delete("account/profile-image")
53
+ async remove(
54
+ @Session() session: UserSession,
55
+ @Req() req: Request,
56
+ ): Promise<ProfileImageResponse> {
57
+ return this.profileImages.remove(
58
+ session.user.image ?? null,
59
+ fromNodeHeaders(req.headers),
60
+ );
61
+ }
62
+
63
+ @Public()
64
+ @Get("profile-images/:fileName")
65
+ async get(
66
+ @Param("fileName") fileName: string,
67
+ @Res({ passthrough: true }) response: Response,
68
+ ): Promise<StreamableFile> {
69
+ const image = await this.profileImages.get(fileName);
70
+ response.setHeader("Cache-Control", "public, max-age=31536000, immutable");
71
+ return new StreamableFile(image.body, { type: image.contentType });
72
+ }
73
+ }
@@ -0,0 +1,9 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { ProfileImageController } from "./profile-image.controller";
3
+ import { ProfileImageService } from "./profile-image.service";
4
+
5
+ @Module({
6
+ controllers: [ProfileImageController],
7
+ providers: [ProfileImageService],
8
+ })
9
+ export class ProfileImageModule {}