@podosoft/podokit 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/add.d.ts +22 -2
  2. package/dist/add.js +71 -19
  3. package/dist/eject.js +9 -1
  4. package/dist/index.js +26 -1
  5. package/dist/lockfile.d.ts +7 -2
  6. package/dist/lockfile.js +22 -2
  7. package/dist/remove.d.ts +28 -0
  8. package/dist/remove.js +170 -0
  9. package/dist/templates/fullstack-nest-svelte/AGENTS.md +2 -1
  10. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.extensions.ts +21 -0
  11. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +3 -0
  12. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +8 -2
  13. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +10 -3
  14. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +37 -0
  15. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/app-sidebar.svelte +12 -11
  16. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +6 -0
  17. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +10 -0
  18. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -1
  19. package/dist/templates/modules/admin-dashboard/module.manifest.json +2 -2
  20. package/dist/templates/modules/auth/module.manifest.json +3 -8
  21. package/dist/templates/modules/mailer/files/apps/api/src/mail/db.ts +13 -0
  22. package/dist/templates/modules/{auth → mailer}/files/apps/api/src/mail/mailer.ts +41 -7
  23. package/dist/templates/modules/mailer/module.manifest.json +38 -0
  24. package/dist/templates/todo/AGENTS.md +2 -1
  25. package/dist/templates/todo/apps/api/src/app.extensions.ts +21 -0
  26. package/dist/templates/todo/apps/api/src/app.module.ts +3 -0
  27. package/dist/templates/todo/apps/web/vite.config.ts +8 -2
  28. package/dist/templates/todo/compose.dev.yaml +10 -3
  29. package/package.json +1 -1
package/dist/add.d.ts CHANGED
@@ -5,7 +5,12 @@ interface Injection {
5
5
  /** Skip (instead of failing) if the target file or marker is absent. */
6
6
  optional?: boolean;
7
7
  }
8
+ /** Highest `module.manifest.json` schema version this CLI understands. A package
9
+ * module declaring a higher version is rejected rather than mis-applied. */
10
+ export declare const SUPPORTED_MANIFEST_VERSION = 1;
8
11
  export interface ModuleManifest {
12
+ /** Manifest schema version. Absent (bundled modules) means "same as the CLI". */
13
+ manifestVersion?: number;
9
14
  name: string;
10
15
  description: string;
11
16
  requires?: string[];
@@ -16,6 +21,11 @@ export interface ModuleManifest {
16
21
  env?: string[];
17
22
  inject?: Injection[];
18
23
  instructions?: string[];
24
+ /** App-relative globs this module's files should own (durably user-editable,
25
+ * never touched by `podo update`). Merged into the project's ownedGlobs so
26
+ * they survive lock recompute. Use for public presentation pages a consumer
27
+ * restyles, while keeping the module's `$lib` logic managed. */
28
+ ownedGlobs?: string[];
19
29
  }
20
30
  export interface AddOptions {
21
31
  projectRoot: string;
@@ -29,9 +39,19 @@ export interface AddResult {
29
39
  instructions: string[];
30
40
  /** Required modules that were auto-added because they were missing. */
31
41
  added: string[];
42
+ /** ownedGlobs declared by this module and every module it pulled in. */
43
+ ownedGlobs: string[];
32
44
  }
33
- /** List modules available under `modulesDir` (each has a module.manifest.json). */
34
- export declare function listModules(modulesDir: string): {
45
+ /**
46
+ * Resolve a module's directory by name: first the bundled `modulesDir`, then a
47
+ * node-resolvable npm package `@podosoft/podokit-module-<name>` installed in the
48
+ * project (or a fully-qualified `@scope/pkg` name). Returns null if not found.
49
+ */
50
+ export declare function resolveModuleDir(name: string, modulesDir: string, projectRoot: string): string | null;
51
+ /** List modules available to a project: the bundled ones under `modulesDir` plus,
52
+ * when `projectRoot` is given, any `@podosoft/podokit-module-*` packages installed
53
+ * in it. Bundled names take precedence on a clash. */
54
+ export declare function listModules(modulesDir: string, projectRoot?: string): {
35
55
  name: string;
36
56
  description: string;
37
57
  }[];
package/dist/add.js CHANGED
@@ -1,21 +1,69 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SUPPORTED_MANIFEST_VERSION = void 0;
4
+ exports.resolveModuleDir = resolveModuleDir;
3
5
  exports.listModules = listModules;
4
6
  exports.addModule = addModule;
5
7
  const node_fs_1 = require("node:fs");
6
8
  const node_path_1 = require("node:path");
9
+ const node_module_1 = require("node:module");
7
10
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
8
11
  const lockfile_1 = require("./lockfile");
9
- /** List modules available under `modulesDir` (each has a module.manifest.json). */
10
- function listModules(modulesDir) {
11
- if (!(0, node_fs_1.existsSync)(modulesDir))
12
- return [];
13
- return (0, node_fs_1.readdirSync)(modulesDir)
14
- .filter((name) => (0, node_fs_1.existsSync)((0, node_path_1.join)(modulesDir, name, "module.manifest.json")))
15
- .map((name) => {
16
- const manifest = readManifest((0, node_path_1.join)(modulesDir, name));
17
- return { name, description: manifest.description };
18
- });
12
+ /** Highest `module.manifest.json` schema version this CLI understands. A package
13
+ * module declaring a higher version is rejected rather than mis-applied. */
14
+ exports.SUPPORTED_MANIFEST_VERSION = 1;
15
+ const PACKAGE_PREFIX = "@podosoft/podokit-module-";
16
+ /**
17
+ * Resolve a module's directory by name: first the bundled `modulesDir`, then a
18
+ * node-resolvable npm package `@podosoft/podokit-module-<name>` installed in the
19
+ * project (or a fully-qualified `@scope/pkg` name). Returns null if not found.
20
+ */
21
+ function resolveModuleDir(name, modulesDir, projectRoot) {
22
+ const bundled = (0, node_path_1.join)(modulesDir, name);
23
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(bundled, "module.manifest.json")))
24
+ return bundled;
25
+ const pkg = name.includes("/") ? name : `${PACKAGE_PREFIX}${name}`;
26
+ try {
27
+ const req = (0, node_module_1.createRequire)((0, node_path_1.join)(projectRoot, "package.json"));
28
+ return (0, node_path_1.dirname)(req.resolve(`${pkg}/module.manifest.json`));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function assertManifestVersion(name, manifest) {
35
+ if ((manifest.manifestVersion ?? exports.SUPPORTED_MANIFEST_VERSION) > exports.SUPPORTED_MANIFEST_VERSION) {
36
+ throw new Error(`Module "${name}" needs a newer PodoKit (manifest v${manifest.manifestVersion}, this CLI supports v${exports.SUPPORTED_MANIFEST_VERSION}). Update @podosoft/podokit.`);
37
+ }
38
+ }
39
+ /** List modules available to a project: the bundled ones under `modulesDir` plus,
40
+ * when `projectRoot` is given, any `@podosoft/podokit-module-*` packages installed
41
+ * in it. Bundled names take precedence on a clash. */
42
+ function listModules(modulesDir, projectRoot) {
43
+ const out = new Map();
44
+ if ((0, node_fs_1.existsSync)(modulesDir)) {
45
+ for (const name of (0, node_fs_1.readdirSync)(modulesDir)) {
46
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(modulesDir, name, "module.manifest.json"))) {
47
+ out.set(name, readManifest((0, node_path_1.join)(modulesDir, name)).description);
48
+ }
49
+ }
50
+ }
51
+ if (projectRoot) {
52
+ const scope = (0, node_path_1.join)(projectRoot, "node_modules", "@podosoft");
53
+ if ((0, node_fs_1.existsSync)(scope)) {
54
+ for (const dir of (0, node_fs_1.readdirSync)(scope)) {
55
+ if (!dir.startsWith("podokit-module-"))
56
+ continue;
57
+ const manifestPath = (0, node_path_1.join)(scope, dir, "module.manifest.json");
58
+ if (!(0, node_fs_1.existsSync)(manifestPath))
59
+ continue;
60
+ const manifest = readManifest((0, node_path_1.join)(scope, dir));
61
+ if (!out.has(manifest.name))
62
+ out.set(manifest.name, manifest.description);
63
+ }
64
+ }
65
+ }
66
+ return [...out].map(([name, description]) => ({ name, description }));
19
67
  }
20
68
  function readManifest(moduleDir) {
21
69
  return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(moduleDir, "module.manifest.json"), "utf8"));
@@ -40,10 +88,10 @@ function appendEnv(projectRoot, lines) {
40
88
  }
41
89
  /** Heuristic: is `module` already applied to the project? */
42
90
  function isApplied(projectRoot, modulesDir, module) {
43
- const manifestPath = (0, node_path_1.join)(modulesDir, module, "module.manifest.json");
44
- if (!(0, node_fs_1.existsSync)(manifestPath))
91
+ const moduleDir = resolveModuleDir(module, modulesDir, projectRoot);
92
+ if (!moduleDir)
45
93
  return false;
46
- const manifest = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf8"));
94
+ const manifest = readManifest(moduleDir);
47
95
  const firstInject = manifest.inject?.[0];
48
96
  if (firstInject) {
49
97
  const target = (0, node_path_1.join)(projectRoot, firstInject.file);
@@ -58,17 +106,19 @@ function isApplied(projectRoot, modulesDir, module) {
58
106
  */
59
107
  function addModule(options) {
60
108
  const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set());
61
- // Record the module (and any auto-added requirements) and refresh the lock.
62
- (0, lockfile_1.recordModules)(options.projectRoot, [...result.added, result.module], options.podokitVersion);
109
+ // Record the module (and any auto-added requirements), fold in the modules'
110
+ // declared ownedGlobs, and refresh the lock.
111
+ (0, lockfile_1.recordModules)(options.projectRoot, [...result.added, result.module], options.podokitVersion, result.ownedGlobs);
63
112
  return result;
64
113
  }
65
114
  function applyModule(projectRoot, module, modulesDir, applied) {
66
- const moduleDir = (0, node_path_1.join)(modulesDir, module);
67
- if (!(0, node_fs_1.existsSync)((0, node_path_1.join)(moduleDir, "module.manifest.json"))) {
68
- const available = listModules(modulesDir).map((m) => m.name);
115
+ const moduleDir = resolveModuleDir(module, modulesDir, projectRoot);
116
+ if (!moduleDir) {
117
+ const available = listModules(modulesDir, projectRoot).map((m) => m.name);
69
118
  throw new Error(`Unknown module "${module}".${available.length ? ` Available: ${available.join(", ")}.` : ""}`);
70
119
  }
71
120
  const manifest = readManifest(moduleDir);
121
+ assertManifestVersion(module, manifest);
72
122
  const appPkgPath = (0, node_path_1.join)(projectRoot, "apps", manifest.targetApp, "package.json");
73
123
  if (!(0, node_fs_1.existsSync)(appPkgPath)) {
74
124
  throw new Error(`This does not look like a PodoKit project: ${(0, node_path_1.join)("apps", manifest.targetApp, "package.json")} not found. Run inside a generated project.`);
@@ -76,11 +126,13 @@ function applyModule(projectRoot, module, modulesDir, applied) {
76
126
  applied.add(module);
77
127
  // 0) apply required modules first (auto-add if missing)
78
128
  const added = [];
129
+ const ownedGlobs = [...(manifest.ownedGlobs ?? [])];
79
130
  for (const required of manifest.requires ?? []) {
80
131
  if (applied.has(required) || isApplied(projectRoot, modulesDir, required))
81
132
  continue;
82
133
  const result = applyModule(projectRoot, required, modulesDir, applied);
83
134
  added.push(required, ...result.added);
135
+ ownedGlobs.push(...result.ownedGlobs);
84
136
  }
85
137
  const appName = projectName(projectRoot);
86
138
  const vars = { projectName: appName };
@@ -119,5 +171,5 @@ function applyModule(projectRoot, module, modulesDir, applied) {
119
171
  (0, node_fs_1.writeFileSync)(target, (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text));
120
172
  }
121
173
  const instructions = (manifest.instructions ?? []).map((line) => line.replace(/<app>/g, appName));
122
- return { module, instructions, added };
174
+ return { module, instructions, added, ownedGlobs };
123
175
  }
package/dist/eject.js CHANGED
@@ -21,7 +21,15 @@ function eject(projectRoot, paths) {
21
21
  result.ejected.push(path);
22
22
  }
23
23
  }
24
- if (result.ejected.length)
24
+ if (result.ejected.length) {
25
25
  (0, lockfile_1.writeFilesLock)(projectRoot, lock);
26
+ // Persist ejected paths into the manifest's ownedGlobs so they stay owned
27
+ // across the next add/update, which rebuilds every tier from ownedGlobs.
28
+ const manifest = (0, lockfile_1.readManifest)(projectRoot);
29
+ if (manifest) {
30
+ manifest.ownedGlobs = (0, lockfile_1.mergeOwnedGlobs)(manifest.ownedGlobs, result.ejected);
31
+ (0, lockfile_1.writeManifest)(projectRoot, manifest);
32
+ }
33
+ }
26
34
  return result;
27
35
  }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ const create_1 = require("./create");
8
8
  const prompt_1 = require("./prompt");
9
9
  const templates_1 = require("./templates");
10
10
  const add_1 = require("./add");
11
+ const remove_1 = require("./remove");
11
12
  const inspect_1 = require("./inspect");
12
13
  const update_1 = require("./update");
13
14
  const eject_1 = require("./eject");
@@ -16,6 +17,7 @@ const HELP = `podo — PodoKit project generator
16
17
  Usage:
17
18
  podo create <name> [options]
18
19
  podo add <module>
20
+ podo remove <module> Un-apply a module (inverse of add)
19
21
  podo status Show version, modules, file tiers, and local edits
20
22
  podo diff List PodoKit-managed files you have edited
21
23
  podo doctor Check framework versions against supported ranges
@@ -89,7 +91,7 @@ async function main(argv) {
89
91
  if (args.command === "add") {
90
92
  const moduleName = args.name;
91
93
  if (!moduleName) {
92
- const available = (0, add_1.listModules)(modulesDir);
94
+ const available = (0, add_1.listModules)(modulesDir, process.cwd());
93
95
  const list = available.length
94
96
  ? available.map((m) => ` ${m.name} ${m.description}`).join("\n")
95
97
  : " (none available)";
@@ -111,6 +113,29 @@ async function main(argv) {
111
113
  }
112
114
  return;
113
115
  }
116
+ if (args.command === "remove") {
117
+ const moduleName = args.name;
118
+ if (!moduleName) {
119
+ fail("Usage: podo remove <module>");
120
+ }
121
+ try {
122
+ const result = (0, remove_1.removeModule)({ projectRoot: process.cwd(), module: moduleName, modulesDir });
123
+ process.stdout.write(`\nRemoved ${result.module}: ${result.removed.length} file(s) deleted` +
124
+ `${result.unwired.length ? `, un-wired ${result.unwired.length} target(s)` : ""}.\n`);
125
+ if (result.keptShared.length) {
126
+ process.stdout.write(`\nKept (shared with another module): ${result.keptShared.join(", ")}\n`);
127
+ }
128
+ if (result.keptEdited.length) {
129
+ process.stdout.write(`\nKept (you edited these — delete manually if you want them gone):\n` +
130
+ result.keptEdited.map((f) => ` ${f}`).join("\n") +
131
+ "\n");
132
+ }
133
+ }
134
+ catch (err) {
135
+ fail(err.message);
136
+ }
137
+ return;
138
+ }
114
139
  if (args.command === "status") {
115
140
  try {
116
141
  const s = (0, inspect_1.status)(process.cwd());
@@ -72,9 +72,14 @@ export interface InitLockOptions {
72
72
  }
73
73
  /** Write the initial `.podokit/` lockfile for a freshly created project. */
74
74
  export declare function initLockfile(projectRoot: string, options: InitLockOptions): void;
75
+ /** Union two glob lists, preserving order and dropping duplicates. Used to fold
76
+ * module-declared and ejected owned paths into the project's `ownedGlobs`, which
77
+ * is the durable source of truth `computeFilesLock` re-reads on every recompute. */
78
+ export declare function mergeOwnedGlobs(existing: string[], add: string[]): string[];
75
79
  /**
76
80
  * Record applied modules in the manifest and recompute the files.lock. Modules
77
81
  * already present are left in place (idempotent); the lock always reflects disk.
78
- * No-op on projects without a manifest (pre-lockfile projects; see `podo adopt`).
82
+ * `ownedGlobs` a module declares are merged into the manifest so its owned paths
83
+ * survive every future recompute. No-op on projects without a manifest.
79
84
  */
80
- export declare function recordModules(projectRoot: string, moduleNames: string[], version?: string): void;
85
+ export declare function recordModules(projectRoot: string, moduleNames: string[], version?: string, ownedGlobs?: string[]): void;
package/dist/lockfile.js CHANGED
@@ -14,6 +14,7 @@ exports.computeFilesLock = computeFilesLock;
14
14
  exports.readFilesLock = readFilesLock;
15
15
  exports.computeDrift = computeDrift;
16
16
  exports.initLockfile = initLockfile;
17
+ exports.mergeOwnedGlobs = mergeOwnedGlobs;
17
18
  exports.recordModules = recordModules;
18
19
  const node_fs_1 = require("node:fs");
19
20
  const node_path_1 = require("node:path");
@@ -44,6 +45,8 @@ const WALK_IGNORE = new Set([
44
45
  exports.DEFAULT_OWNED_GLOBS = [
45
46
  "apps/web/src/routes/**",
46
47
  "apps/web/src/lib/components/ui/**",
48
+ // Owned Nest DI extension slot — override providers here; update never touches it.
49
+ "apps/api/src/app.extensions.ts",
47
50
  // AI agent guidance — yours to customize; update never touches it.
48
51
  "AGENTS.md",
49
52
  "CLAUDE.md",
@@ -186,12 +189,27 @@ function initLockfile(projectRoot, options) {
186
189
  writeManifest(projectRoot, manifest);
187
190
  writeFilesLock(projectRoot, computeFilesLock(projectRoot, ownedGlobs));
188
191
  }
192
+ /** Union two glob lists, preserving order and dropping duplicates. Used to fold
193
+ * module-declared and ejected owned paths into the project's `ownedGlobs`, which
194
+ * is the durable source of truth `computeFilesLock` re-reads on every recompute. */
195
+ function mergeOwnedGlobs(existing, add) {
196
+ const seen = new Set(existing);
197
+ const out = [...existing];
198
+ for (const glob of add) {
199
+ if (seen.has(glob))
200
+ continue;
201
+ seen.add(glob);
202
+ out.push(glob);
203
+ }
204
+ return out;
205
+ }
189
206
  /**
190
207
  * Record applied modules in the manifest and recompute the files.lock. Modules
191
208
  * already present are left in place (idempotent); the lock always reflects disk.
192
- * No-op on projects without a manifest (pre-lockfile projects; see `podo adopt`).
209
+ * `ownedGlobs` a module declares are merged into the manifest so its owned paths
210
+ * survive every future recompute. No-op on projects without a manifest.
193
211
  */
194
- function recordModules(projectRoot, moduleNames, version) {
212
+ function recordModules(projectRoot, moduleNames, version, ownedGlobs) {
195
213
  const manifest = readManifest(projectRoot);
196
214
  if (!manifest)
197
215
  return;
@@ -203,6 +221,8 @@ function recordModules(projectRoot, moduleNames, version) {
203
221
  manifest.modules.push({ name, order: manifest.modules.length, addedWith: stampedWith });
204
222
  known.add(name);
205
223
  }
224
+ if (ownedGlobs?.length)
225
+ manifest.ownedGlobs = mergeOwnedGlobs(manifest.ownedGlobs, ownedGlobs);
206
226
  writeManifest(projectRoot, manifest);
207
227
  writeFilesLock(projectRoot, computeFilesLock(projectRoot, manifest.ownedGlobs));
208
228
  }
@@ -0,0 +1,28 @@
1
+ export interface RemoveOptions {
2
+ projectRoot: string;
3
+ module: string;
4
+ modulesDir: string;
5
+ /** PodoKit version stamped into the lockfile. Defaults to the CLI version. */
6
+ podokitVersion?: string;
7
+ }
8
+ export interface RemoveResult {
9
+ module: string;
10
+ /** Module overlay files deleted from the project. */
11
+ removed: string[];
12
+ /** Module files kept because the user edited them (delete manually if desired). */
13
+ keptEdited: string[];
14
+ /** Module files kept because another installed module also ships them. */
15
+ keptShared: string[];
16
+ /** Injection targets whose wiring was removed. */
17
+ unwired: string[];
18
+ }
19
+ /**
20
+ * Remove a previously-added module from a generated project: the inverse of
21
+ * `podo add`. Un-wires the module's marker injections, deletes its overlay files
22
+ * (keeping any the user edited or another installed module also ships), prunes
23
+ * the package.json deps/scripts and .env.example lines it introduced that no
24
+ * other installed module still needs, then drops it from the manifest and
25
+ * recomputes the lock. Does not cascade: a module still required by another
26
+ * installed module is refused rather than force-removed.
27
+ */
28
+ export declare function removeModule(options: RemoveOptions): RemoveResult;
package/dist/remove.js ADDED
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.removeModule = removeModule;
4
+ const node_fs_1 = require("node:fs");
5
+ const node_path_1 = require("node:path");
6
+ const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
7
+ const add_1 = require("./add");
8
+ const lockfile_1 = require("./lockfile");
9
+ function readModuleManifest(moduleDir) {
10
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(moduleDir, "module.manifest.json"), "utf8"));
11
+ }
12
+ function readJson(path) {
13
+ return JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
14
+ }
15
+ function projectName(projectRoot) {
16
+ const pkg = readJson((0, node_path_1.join)(projectRoot, "package.json"));
17
+ return typeof pkg.name === "string" ? pkg.name : "app";
18
+ }
19
+ /** Output paths a module writes into a project (dot- convention applied). */
20
+ function moduleFilePaths(moduleDir, vars) {
21
+ const filesDir = (0, node_path_1.join)(moduleDir, "files");
22
+ if (!(0, node_fs_1.existsSync)(filesDir))
23
+ return [];
24
+ return [...(0, podokit_template_engine_1.renderTemplate)(filesDir, vars).keys()];
25
+ }
26
+ /**
27
+ * Remove a previously-added module from a generated project: the inverse of
28
+ * `podo add`. Un-wires the module's marker injections, deletes its overlay files
29
+ * (keeping any the user edited or another installed module also ships), prunes
30
+ * the package.json deps/scripts and .env.example lines it introduced that no
31
+ * other installed module still needs, then drops it from the manifest and
32
+ * recomputes the lock. Does not cascade: a module still required by another
33
+ * installed module is refused rather than force-removed.
34
+ */
35
+ function removeModule(options) {
36
+ const { projectRoot, module, modulesDir } = options;
37
+ const manifest = (0, lockfile_1.readManifest)(projectRoot);
38
+ if (!manifest) {
39
+ throw new Error("Not a PodoKit project: .podokit/manifest.json not found.");
40
+ }
41
+ if (!manifest.modules.some((m) => m.name === module)) {
42
+ const installed = manifest.modules.map((m) => m.name).join(", ") || "(none)";
43
+ throw new Error(`Module "${module}" is not installed. Installed: ${installed}.`);
44
+ }
45
+ const moduleDir = (0, add_1.resolveModuleDir)(module, modulesDir, projectRoot);
46
+ if (!moduleDir) {
47
+ throw new Error(`Cannot resolve module "${module}" to un-apply it. Is it still installed/bundled?`);
48
+ }
49
+ const targetManifest = readModuleManifest(moduleDir);
50
+ // Resolve the manifests of the OTHER installed modules once — used for the
51
+ // requires guard and to decide which files/deps/env are shared.
52
+ const others = manifest.modules
53
+ .filter((m) => m.name !== module)
54
+ .map((m) => {
55
+ const dir = (0, add_1.resolveModuleDir)(m.name, modulesDir, projectRoot);
56
+ return dir ? { name: m.name, dir, manifest: readModuleManifest(dir) } : null;
57
+ })
58
+ .filter((m) => m !== null);
59
+ // Guard: refuse if another installed module requires this one.
60
+ const dependents = others.filter((o) => (o.manifest.requires ?? []).includes(module)).map((o) => o.name);
61
+ if (dependents.length) {
62
+ throw new Error(`Cannot remove "${module}": required by ${dependents.join(", ")}. Remove ${dependents.length > 1 ? "those" : "that"} first.`);
63
+ }
64
+ const vars = { projectName: projectName(projectRoot) };
65
+ const sharedFiles = new Set(others.flatMap((o) => moduleFilePaths(o.dir, vars)));
66
+ const lock = (0, lockfile_1.readFilesLock)(projectRoot);
67
+ // 1) un-wire marker injections (idempotent; skips optional/absent).
68
+ const unwired = [];
69
+ for (const injection of targetManifest.inject ?? []) {
70
+ const target = (0, node_path_1.join)(projectRoot, injection.file);
71
+ if (!(0, node_fs_1.existsSync)(target))
72
+ continue;
73
+ const content = (0, node_fs_1.readFileSync)(target, "utf8");
74
+ const next = (0, podokit_template_engine_1.removeAtMarker)(content, injection.text);
75
+ if (next !== content) {
76
+ (0, node_fs_1.writeFileSync)(target, next);
77
+ unwired.push(injection.file);
78
+ }
79
+ }
80
+ // 2) delete overlay files, preserving edited and shared ones.
81
+ const removed = [];
82
+ const keptEdited = [];
83
+ const keptShared = [];
84
+ for (const rel of moduleFilePaths(moduleDir, vars)) {
85
+ const abs = (0, node_path_1.join)(projectRoot, rel);
86
+ if (!(0, node_fs_1.existsSync)(abs))
87
+ continue;
88
+ if (sharedFiles.has(rel)) {
89
+ keptShared.push(rel);
90
+ continue;
91
+ }
92
+ const locked = lock?.files[rel];
93
+ if (locked && (0, podokit_template_engine_1.hashContent)((0, node_fs_1.readFileSync)(abs)) !== locked.outHash) {
94
+ keptEdited.push(rel);
95
+ continue;
96
+ }
97
+ (0, node_fs_1.rmSync)(abs);
98
+ pruneEmptyDirs(projectRoot, (0, node_path_1.dirname)(rel));
99
+ removed.push(rel);
100
+ }
101
+ // 3) prune package.json deps/scripts this module added that no other needs.
102
+ pruneAppPackage(projectRoot, targetManifest, others);
103
+ // 4) prune .env.example lines this module added that no other needs.
104
+ pruneEnvExample(projectRoot, targetManifest, others);
105
+ // 5) drop from the manifest (re-number order) and recompute the lock.
106
+ manifest.modules = manifest.modules
107
+ .filter((m) => m.name !== module)
108
+ .map((m, i) => ({ ...m, order: i }));
109
+ (0, lockfile_1.writeManifest)(projectRoot, manifest);
110
+ (0, lockfile_1.writeFilesLock)(projectRoot, (0, lockfile_1.computeFilesLock)(projectRoot, manifest.ownedGlobs));
111
+ return { module, removed, keptEdited, keptShared, unwired };
112
+ }
113
+ /** Remove now-empty directories left behind by a deleted file, up to the root. */
114
+ function pruneEmptyDirs(projectRoot, relDir) {
115
+ let dir = relDir;
116
+ while (dir && dir !== "." && dir !== "/") {
117
+ const abs = (0, node_path_1.join)(projectRoot, dir);
118
+ try {
119
+ (0, node_fs_1.rmdirSync)(abs); // removes only an empty directory; throws otherwise
120
+ }
121
+ catch {
122
+ return; // not empty (or gone) — stop climbing
123
+ }
124
+ dir = (0, node_path_1.dirname)(dir);
125
+ }
126
+ }
127
+ function pruneAppPackage(projectRoot, target, others) {
128
+ if (!(target.dependencies || target.devDependencies || target.scripts))
129
+ return;
130
+ const appPkgPath = (0, node_path_1.join)(projectRoot, "apps", target.targetApp, "package.json");
131
+ if (!(0, node_fs_1.existsSync)(appPkgPath))
132
+ return;
133
+ const keep = (section) => new Set(others.flatMap((o) => Object.keys(o.manifest[section] ?? {})));
134
+ const pkg = readJson(appPkgPath);
135
+ let changed = false;
136
+ for (const section of ["dependencies", "devDependencies", "scripts"]) {
137
+ const declared = target[section];
138
+ const current = pkg[section];
139
+ if (!declared || !current)
140
+ continue;
141
+ const shared = keep(section);
142
+ for (const name of Object.keys(declared)) {
143
+ if (shared.has(name))
144
+ continue;
145
+ if (name in current) {
146
+ delete current[name];
147
+ changed = true;
148
+ }
149
+ }
150
+ if (current && Object.keys(current).length === 0)
151
+ delete pkg[section];
152
+ }
153
+ if (changed)
154
+ (0, node_fs_1.writeFileSync)(appPkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
155
+ }
156
+ function pruneEnvExample(projectRoot, target, others) {
157
+ if (!target.env?.length)
158
+ return;
159
+ const file = (0, node_path_1.join)(projectRoot, ".env.example");
160
+ if (!(0, node_fs_1.existsSync)(file))
161
+ return;
162
+ const shared = new Set(others.flatMap((o) => o.manifest.env ?? []));
163
+ const drop = new Set(target.env.filter((line) => !shared.has(line)));
164
+ if (!drop.size)
165
+ return;
166
+ const kept = (0, node_fs_1.readFileSync)(file, "utf8")
167
+ .split("\n")
168
+ .filter((line) => !drop.has(line));
169
+ (0, node_fs_1.writeFileSync)(file, kept.join("\n"));
170
+ }
@@ -59,13 +59,14 @@ changes need an e2e/unit test.
59
59
  This project is managed by the `podo` CLI. Files have an ownership tier recorded
60
60
  in `.podokit/files.lock` — respect it:
61
61
 
62
- - **owned** (your code: `apps/web/src/routes/**`, your components/services, and this `AGENTS.md`) — edit freely.
62
+ - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. To add or override Nest providers/modules, use `app.extensions.ts` (spread into `AppModule` after the module-wired providers, so a same-token override wins) rather than editing `app.module.ts`.
63
63
  - **assembled** (e.g. `apps/api/src/app.module.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
64
64
  - **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
65
65
 
66
66
  Useful commands:
67
67
 
68
68
  - `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
69
+ - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
69
70
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
70
71
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
71
72
 
@@ -0,0 +1,21 @@
1
+ import type { DynamicModule, Provider, Type } from "@nestjs/common";
2
+
3
+ // Owned extension slot for the Nest app. This file is yours — PodoKit never
4
+ // writes to it, so `podo update` leaves it alone. Use it to add your own modules
5
+ // or providers, or to override a PodoKit-provided provider without editing
6
+ // managed code. Because these are spread in *after* the module-wired providers,
7
+ // an override here takes precedence.
8
+ //
9
+ // Example — swap the mailer transport for your own implementation:
10
+ // import { Mailer } from "./mailer/mailer";
11
+ // import { MyMailer } from "./my-mailer";
12
+ // export const extensionProviders: Provider[] = [
13
+ // { provide: Mailer, useClass: MyMailer },
14
+ // ];
15
+
16
+ /** Extra modules to import into AppModule (or DynamicModule overrides). */
17
+ export const extensionImports: (Type | DynamicModule)[] = [];
18
+
19
+ /** Extra or overriding providers for AppModule. Same-token providers listed here
20
+ * win over the ones PodoKit modules registered. */
21
+ export const extensionProviders: Provider[] = [];
@@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
4
4
  import { validateEnv } from "./config/env.validation";
5
5
  import { dataSourceOptions } from "./database/data-source";
6
6
  import { HealthModule } from "./health/health.module";
7
+ import { extensionImports, extensionProviders } from "./app.extensions";
7
8
  // podokit:begin:imports
8
9
  // podokit:end:imports
9
10
 
@@ -14,10 +15,12 @@ import { HealthModule } from "./health/health.module";
14
15
  HealthModule,
15
16
  // podokit:begin:module-imports
16
17
  // podokit:end:module-imports
18
+ ...extensionImports,
17
19
  ],
18
20
  providers: [
19
21
  // podokit:begin:providers
20
22
  // podokit:end:providers
23
+ ...extensionProviders,
21
24
  ],
22
25
  })
23
26
  export class AppModule {}
@@ -3,8 +3,14 @@ import tailwindcss from "@tailwindcss/vite";
3
3
  import { defineConfig } from "vite";
4
4
 
5
5
  // Inside the dev container (compose.dev.yaml sets VITE_DOCKER=1): bind to all
6
- // interfaces, allow the Traefik hostname, and route HMR through Traefik on :80.
6
+ // interfaces, allow the Traefik hostname, and route HMR's WebSocket back through
7
+ // Traefik. The browser reaches the app at Traefik's published host port, so the
8
+ // HMR client must target that same port — otherwise the ws connects to :80,
9
+ // fails, and Vite silently falls back to a full page reload on every change
10
+ // (which wipes in-progress form input). compose.dev.yaml injects
11
+ // VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
7
12
  const inDocker = process.env.VITE_DOCKER === "1";
13
+ const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
8
14
 
9
15
  export default defineConfig({
10
16
  plugins: [tailwindcss(), sveltekit()],
@@ -13,7 +19,7 @@ export default defineConfig({
13
19
  host: true,
14
20
  port: 5001,
15
21
  allowedHosts: true,
16
- hmr: { clientPort: 80 },
22
+ hmr: { clientPort: hmrClientPort },
17
23
  }
18
24
  : undefined,
19
25
  });
@@ -4,7 +4,10 @@
4
4
  # docker compose -f compose.dev.yaml --profile cache \
5
5
  # --profile storage --profile queue watch # with modules
6
6
  #
7
- # Only Traefik binds a host port (80). The app is at http://app.localhost.
7
+ # Only Traefik binds a host port (default 80). The app is at http://app.localhost.
8
+ # Set TRAEFIK_PORT to publish on another port (e.g. TRAEFIK_PORT=8001) so several
9
+ # containerized stacks can run at once without colliding — the web container reads
10
+ # the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
8
11
  # api/postgres/redis/minio have NO published ports — they talk by service name
9
12
  # on the internal network, so this stack never collides with other projects.
10
13
  #
@@ -29,8 +32,8 @@ services:
29
32
  - --api.dashboard=true
30
33
  - --api.insecure=true
31
34
  ports:
32
- - "80:80"
33
- - "127.0.0.1:8080:8080" # Traefik dashboard (dev only)
35
+ - "${TRAEFIK_PORT:-80}:80"
36
+ - "127.0.0.1:${TRAEFIK_DASHBOARD_PORT:-8080}:8080" # Traefik dashboard (dev only)
34
37
  volumes:
35
38
  - ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
36
39
  networks: [podokit]
@@ -45,6 +48,10 @@ services:
45
48
  environment:
46
49
  - VITE_DOCKER=1
47
50
  - BACKEND_INTERNAL_URL=http://api:5002
51
+ # Match Traefik's published port so HMR's WebSocket targets the right port
52
+ # (default 80). Without this, HMR connects to :80 and a non-80 stack falls
53
+ # back to a full page reload on every edit.
54
+ - VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
48
55
  depends_on: [api]
49
56
  networks: [podokit]
50
57
  develop:
@@ -0,0 +1,37 @@
1
+ import type { Component } from "svelte";
2
+ import type { Messages } from "$lib/i18n/messages";
3
+ // Modules import their nav-entry icons here.
4
+ // podokit:begin:admin-nav-imports
5
+ // podokit:end:admin-nav-imports
6
+
7
+ // Module-driven admin menu registry. Modules append their sidebar nav entries and
8
+ // settings tabs at the markers below via `podo add` injection, so the admin menu
9
+ // grows and shrinks with the installed module set — no edits to app-sidebar or
10
+ // the settings page. Empty until a module registers.
11
+
12
+ /** A sidebar nav entry contributed by a module: a link, a label resolved from the
13
+ * current locale (from the module's own i18n block), and an icon. */
14
+ export type AdminNavEntry = {
15
+ href: string;
16
+ label: (t: Messages) => string;
17
+ icon: Component;
18
+ adminOnly?: boolean;
19
+ };
20
+
21
+ /** A settings tab contributed by a module: a tab value, a label resolved from the
22
+ * current locale, and the panel component. */
23
+ export type AdminSettingsSection = {
24
+ value: string;
25
+ label: (t: Messages) => string;
26
+ component: Component;
27
+ };
28
+
29
+ export const moduleNavEntries: AdminNavEntry[] = [
30
+ // podokit:begin:admin-nav-entries
31
+ // podokit:end:admin-nav-entries
32
+ ];
33
+
34
+ export const moduleSettingsSections: AdminSettingsSection[] = [
35
+ // podokit:begin:admin-settings
36
+ // podokit:end:admin-settings
37
+ ];
@@ -10,25 +10,26 @@
10
10
  import SettingsIcon from "@lucide/svelte/icons/settings";
11
11
  import HouseIcon from "@lucide/svelte/icons/house";
12
12
  import { getI18n } from "$lib/i18n";
13
+ import { moduleNavEntries, type AdminNavEntry } from "$lib/admin/registry.svelte";
13
14
  import type { SessionUser } from "../../app.d.ts";
14
- import type { Messages } from "$lib/i18n/messages";
15
- import type { Component } from "svelte";
16
15
 
17
16
  let { user, capabilities }: { user: SessionUser; capabilities?: { auditLog?: boolean; organization?: boolean } } = $props();
18
17
  const i18n = getI18n();
19
18
 
20
- type NavItem = { href: string; key: keyof Messages["nav"]; icon: Component; adminOnly?: boolean };
19
+ type NavItem = AdminNavEntry;
21
20
  const items = $derived<NavItem[]>([
22
- { href: "/admin", key: "overview", icon: LayoutDashboardIcon },
23
- { href: "/admin/users", key: "users", icon: UsersIcon, adminOnly: true },
24
- { href: "/admin/sessions", key: "sessions", icon: MonitorSmartphoneIcon, adminOnly: true },
21
+ { href: "/admin", label: (t) => t.nav.overview, icon: LayoutDashboardIcon },
22
+ { href: "/admin/users", label: (t) => t.nav.users, icon: UsersIcon, adminOnly: true },
23
+ { href: "/admin/sessions", label: (t) => t.nav.sessions, icon: MonitorSmartphoneIcon, adminOnly: true },
25
24
  ...(capabilities?.organization
26
- ? [{ href: "/admin/organizations", key: "organizations", icon: Building2Icon, adminOnly: true } as NavItem]
25
+ ? [{ href: "/admin/organizations", label: (t) => t.nav.organizations, icon: Building2Icon, adminOnly: true } as NavItem]
27
26
  : []),
28
27
  ...(capabilities?.auditLog
29
- ? [{ href: "/admin/audit", key: "audit", icon: ScrollTextIcon, adminOnly: true } as NavItem]
28
+ ? [{ href: "/admin/audit", label: (t) => t.nav.audit, icon: ScrollTextIcon, adminOnly: true } as NavItem]
30
29
  : []),
31
- { href: "/admin/settings", key: "settings", icon: SettingsIcon, adminOnly: true },
30
+ // Module-contributed entries (present only for installed modules).
31
+ ...moduleNavEntries,
32
+ { href: "/admin/settings", label: (t) => t.nav.settings, icon: SettingsIcon, adminOnly: true },
32
33
  ]);
33
34
  const visible = $derived(items.filter((item) => !item.adminOnly || user.role === "admin"));
34
35
  </script>
@@ -47,11 +48,11 @@
47
48
  <Sidebar.Menu>
48
49
  {#each visible as item (item.href)}
49
50
  <Sidebar.MenuItem>
50
- <Sidebar.MenuButton isActive={page.url.pathname === item.href} tooltipContent={i18n.t.nav[item.key]}>
51
+ <Sidebar.MenuButton isActive={page.url.pathname === item.href} tooltipContent={item.label(i18n.t)}>
51
52
  {#snippet child({ props })}
52
53
  <a href={item.href} {...props}>
53
54
  <item.icon />
54
- <span>{i18n.t.nav[item.key]}</span>
55
+ <span>{item.label(i18n.t)}</span>
55
56
  </a>
56
57
  {/snippet}
57
58
  </Sidebar.MenuButton>
@@ -307,6 +307,9 @@ const en = {
307
307
  verified: "Verified",
308
308
  unverified: "Unverified",
309
309
  },
310
+ // Modules add their i18n blocks here.
311
+ // podokit:begin:messages-en
312
+ // podokit:end:messages-en
310
313
  };
311
314
 
312
315
  export type Messages = typeof en;
@@ -610,6 +613,9 @@ const ko: Messages = {
610
613
  verified: "인증됨",
611
614
  unverified: "미인증",
612
615
  },
616
+ // Modules add their i18n blocks here.
617
+ // podokit:begin:messages-ko
618
+ // podokit:end:messages-ko
613
619
  };
614
620
 
615
621
  export const messages: Record<Locale, Messages> = { en, ko };
@@ -9,6 +9,7 @@
9
9
  import * as Dialog from "$lib/components/ui/dialog";
10
10
  import * as Tabs from "$lib/components/ui/tabs";
11
11
  import GeneralSettings from "./general-settings.svelte";
12
+ import { moduleSettingsSections } from "$lib/admin/registry.svelte";
12
13
  import { toast } from "svelte-sonner";
13
14
  import { api } from "$lib/api";
14
15
  import { getI18n } from "$lib/i18n";
@@ -226,6 +227,9 @@
226
227
  <Tabs.List>
227
228
  <Tabs.Trigger value="general">{i18n.t.general.tab}</Tabs.Trigger>
228
229
  <Tabs.Trigger value="auth">{i18n.t.settings.tab}</Tabs.Trigger>
230
+ {#each moduleSettingsSections as s (s.value)}
231
+ <Tabs.Trigger value={s.value}>{s.label(i18n.t)}</Tabs.Trigger>
232
+ {/each}
229
233
  </Tabs.List>
230
234
 
231
235
  <Tabs.Content value="general" class="mt-6">
@@ -247,6 +251,12 @@
247
251
  </section>
248
252
  {/each}
249
253
  </Tabs.Content>
254
+
255
+ {#each moduleSettingsSections as s (s.value)}
256
+ <Tabs.Content value={s.value} class="mt-6">
257
+ <s.component />
258
+ </Tabs.Content>
259
+ {/each}
250
260
  </Tabs.Root>
251
261
  </div>
252
262
 
@@ -4,7 +4,17 @@ import { redirect } from "@sveltejs/kit";
4
4
  // "/" is a public landing page. Auth pages send signed-in users to /admin, and
5
5
  // the admin area requires a session. /maintenance is public so it renders while
6
6
  // the rest of the site is held back.
7
- const PUBLIC_PATHS = ["/", "/login", "/signup", "/forgot-password", "/reset-password", "/maintenance"];
7
+ const PUBLIC_PATHS = [
8
+ "/",
9
+ "/login",
10
+ "/signup",
11
+ "/forgot-password",
12
+ "/reset-password",
13
+ "/maintenance",
14
+ // Modules add their public (no-session) page prefixes here.
15
+ // podokit:begin:public-paths
16
+ // podokit:end:public-paths
17
+ ];
8
18
  const AUTH_PATHS = ["/login", "/signup", "/forgot-password", "/reset-password"];
9
19
 
10
20
  function isPublic(pathname: string): boolean {
@@ -59,7 +59,7 @@
59
59
  "Set ADMIN_EMAILS in .env (comma-separated). Those emails become admins on sign-up.",
60
60
  "Re-run the auth migration to add the admin columns: npx @better-auth/cli migrate -y --config apps/api/src/auth/auth.ts",
61
61
  "Start the app (npm run dev), open /signup, register an ADMIN_EMAILS address, then sign in.",
62
- "Admin pages: /dashboard/users (list, ban, set role, revoke sessions). Everyone: /dashboard/sessions, /dashboard/account.",
63
- "Password reset: the reset link is logged to the API console in dev; wire a real mailer via emailAndPassword.sendResetPassword for production."
62
+ "Admin pages: /admin/users (list, ban, set role, revoke sessions). Everyone: /admin/sessions, /admin/account.",
63
+ "Password reset emails go through the mailer module (installed automatically with auth); in dev without SMTP they are logged to the API console. Configure SMTP in admin settings or via SMTP_* env for production."
64
64
  ]
65
65
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "auth",
3
3
  "description": "Full authentication (better-auth): email/password, sessions, and \u2014 via config \u2014 OAuth and 2FA. Installs a global guard so the API is secure by default; health and /api/auth stay public.",
4
- "requires": [],
4
+ "requires": ["mailer"],
5
5
  "targetApp": "api",
6
6
  "dependencies": {
7
7
  "@podosoft/podokit-auth": "^0.1.0",
@@ -9,14 +9,12 @@
9
9
  "better-auth": "^1.6.23",
10
10
  "@thallesp/nestjs-better-auth": "^2.7.0",
11
11
  "pg": "^8.12.0",
12
- "nodemailer": "^7.0.9",
13
12
  "@better-auth/api-key": "^1.6.23",
14
13
  "@better-auth/passkey": "^1.6.23",
15
14
  "@better-auth/oauth-provider": "^1.6.23"
16
15
  },
17
16
  "devDependencies": {
18
- "@types/pg": "^8.11.10",
19
- "@types/nodemailer": "^7.0.4"
17
+ "@types/pg": "^8.11.10"
20
18
  },
21
19
  "env": [
22
20
  "# \u2500\u2500 Auth (better-auth) \u2014 the only required secrets \u2500\u2500",
@@ -32,10 +30,7 @@
32
30
  "# override (e.g. for secrets-manager injection in production); the DB takes",
33
31
  "# precedence when set.",
34
32
  "# --- Optional overrides / local development ---",
35
- "# Email (Mailpit in local dev: docker compose --profile dev up \u2192 http://localhost:8025)",
36
- "# SMTP_HOST=localhost",
37
- "# SMTP_PORT=1025",
38
- "# MAIL_FROM=PodoKit <no-reply@example.com>",
33
+ "# Email/SMTP is provided by the mailer module (see its .env block).",
39
34
  "# Phone-number SMS webhook (dev: SMS sink on port 8095)",
40
35
  "# SMS_WEBHOOK_URL=http://localhost:8095/sms",
41
36
  "# AUTH_HIBP=false",
@@ -0,0 +1,13 @@
1
+ import { Pool } from "pg";
2
+
3
+ // The mailer's own pg pool, in its own module so the mailer stays independent of
4
+ // any other module. Used only to read the optional admin-configured SMTP settings
5
+ // from `auth_config` when the auth module is present; with no such table the read
6
+ // fails softly and the mailer falls back to SMTP_* env (see mailer.ts).
7
+ export const pool = new Pool({
8
+ host: process.env.POSTGRES_HOST ?? "localhost",
9
+ port: Number(process.env.POSTGRES_PORT ?? 5432),
10
+ user: process.env.POSTGRES_USER ?? "podokit",
11
+ password: process.env.POSTGRES_PASSWORD ?? "podokit",
12
+ database: process.env.POSTGRES_DB ?? "podokit",
13
+ });
@@ -1,14 +1,40 @@
1
1
  import nodemailer, { type Transporter } from "nodemailer";
2
- import { pool } from "../auth/db";
2
+ import { pool } from "./db";
3
3
  import { createConfigStore, type SmtpConfig } from "@podosoft/podokit-auth";
4
4
 
5
- // SMTP is admin-configurable in the DB (auth_config), falling back to SMTP_* env,
6
- // and applied live: the transport is rebuilt when the config changes (short TTL),
7
- // so no restart is needed. With neither DB nor env config, fall back to a transport
8
- // that just logs the message so nothing crashes for lack of mail config.
5
+ // Reusable email sending. SMTP is resolved in priority order, applied live (short
6
+ // TTL) so no restart is needed:
7
+ // 1. admin-configured DB settings (auth_config) — only when the auth module is
8
+ // installed; read via the config store, which decrypts the SMTP password;
9
+ // 2. SMTP_* env (fallback / production secrets-manager injection);
10
+ // 3. none → a JSON transport that logs the message so dev links are grabbable.
11
+ // The whole transport can also be overridden from app code (setMailTransport),
12
+ // e.g. from apps/api/src/app.extensions.ts, to plug in a provider SDK.
9
13
  const store = createConfigStore(pool);
10
14
  const TTL_MS = 3_000;
11
15
 
16
+ function envSmtp(): SmtpConfig | null {
17
+ if (!process.env.SMTP_HOST) return null;
18
+ return {
19
+ host: process.env.SMTP_HOST,
20
+ port: Number(process.env.SMTP_PORT ?? 1025),
21
+ secure: process.env.SMTP_SECURE === "true",
22
+ user: process.env.SMTP_USER,
23
+ pass: process.env.SMTP_PASS,
24
+ from: process.env.MAIL_FROM,
25
+ };
26
+ }
27
+
28
+ async function resolveSmtp(): Promise<SmtpConfig | null> {
29
+ try {
30
+ // Reads auth_config and falls back to env internally when auth is installed.
31
+ return await store.smtpConfig();
32
+ } catch {
33
+ // No auth_config table (auth not installed) — use env directly.
34
+ return envSmtp();
35
+ }
36
+ }
37
+
12
38
  function buildTransport(smtp: SmtpConfig | null): Transporter {
13
39
  if (!smtp) return nodemailer.createTransport({ jsonTransport: true });
14
40
  return nodemailer.createTransport({
@@ -24,12 +50,20 @@ let transportFrom = process.env.MAIL_FROM ?? "PodoKit <no-reply@example.com>";
24
50
  let hasSmtp = false;
25
51
  let checkedAt = 0;
26
52
  let signature = "";
53
+ let override: Transporter | null = null;
54
+
55
+ /** Override the transport from app code (e.g. a provider SDK). Pass null to
56
+ * restore the config-driven transport. Set this from app.extensions.ts. */
57
+ export function setMailTransport(custom: Transporter | null): void {
58
+ override = custom;
59
+ }
27
60
 
28
61
  async function getTransport(): Promise<Transporter> {
62
+ if (override) return override;
29
63
  if (checkedAt !== 0 && Date.now() - checkedAt < TTL_MS) return transport;
30
64
  checkedAt = Date.now();
31
65
  try {
32
- const smtp = await store.smtpConfig();
66
+ const smtp = await resolveSmtp();
33
67
  const next = JSON.stringify(smtp ?? null);
34
68
  if (next !== signature) {
35
69
  transport = buildTransport(smtp);
@@ -48,7 +82,7 @@ export type Mail = { to: string; subject: string; text: string; html?: string };
48
82
  export async function sendMail(mail: Mail): Promise<void> {
49
83
  const t = await getTransport();
50
84
  const info = await t.sendMail({ from: transportFrom, ...mail });
51
- if (!hasSmtp) {
85
+ if (!override && !hasSmtp) {
52
86
  // JSON transport: surface the message so the link is grabbable from logs.
53
87
  console.log(`[mailer] no SMTP configured; message not delivered:\n${info.message as string}`);
54
88
  }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "mailer",
3
+ "description": "Reusable email sending (nodemailer). Resolves SMTP from the admin-configured DB settings when auth is installed, else SMTP_* env, else logs to the console. Used by auth and contact-form; the transport is overridable from app.extensions.ts.",
4
+ "requires": [],
5
+ "targetApp": "api",
6
+ "dependencies": {
7
+ "nodemailer": "^7.0.9",
8
+ "@podosoft/podokit-auth": "^0.1.0",
9
+ "pg": "^8.12.0"
10
+ },
11
+ "devDependencies": {
12
+ "@types/nodemailer": "^7.0.4",
13
+ "@types/pg": "^8.11.10"
14
+ },
15
+ "env": [
16
+ "# ── Mailer (SMTP) — optional ──",
17
+ "# With auth installed, configure SMTP on the admin Settings page (stored",
18
+ "# encrypted in the DB, applied live). These env vars are a fallback/override;",
19
+ "# with no SMTP configured at all the mailer logs messages to the console.",
20
+ "# Local dev uses Mailpit: docker compose --profile dev up → http://localhost:8025",
21
+ "# SMTP_HOST=localhost",
22
+ "# SMTP_PORT=1025",
23
+ "# MAIL_FROM=PodoKit <no-reply@example.com>"
24
+ ],
25
+ "inject": [
26
+ {
27
+ "file": "AGENTS.md",
28
+ "marker": "<!-- podokit:end:agents-modules -->",
29
+ "optional": true,
30
+ "text": "### mailer\n- Send email with `import { sendMail, actionEmail } from \"../mail/mailer\"` (path relative to your file). `sendMail({ to, subject, text, html? })`.\n- SMTP comes from the admin Settings page (when auth is installed) or `SMTP_*` env; with neither, messages are logged to the console (dev links are grabbable from logs).\n- To use a provider SDK instead of SMTP, call `setMailTransport(...)` from `apps/api/src/app.extensions.ts`.\n"
31
+ }
32
+ ],
33
+ "instructions": [
34
+ "Send mail from anywhere in the API: import { sendMail } from \"../mail/mailer\" then await sendMail({ to, subject, text }).",
35
+ "SMTP is optional: with auth installed, configure it on the admin Settings page (DB, applied live); otherwise set SMTP_HOST/SMTP_PORT/MAIL_FROM in .env. With none set, messages are logged to the console.",
36
+ "To send through a provider SDK, override the transport in apps/api/src/app.extensions.ts: import { setMailTransport } from \"./mail/mailer\"; setMailTransport(myTransport)."
37
+ ]
38
+ }
@@ -60,13 +60,14 @@ changes need an e2e/unit test.
60
60
  This project is managed by the `podo` CLI. Files have an ownership tier recorded
61
61
  in `.podokit/files.lock` — respect it:
62
62
 
63
- - **owned** (your code: `apps/web/src/routes/**`, your components/services, and this `AGENTS.md`) — edit freely.
63
+ - **owned** (your code: `apps/web/src/routes/**`, your components/services, `apps/api/src/app.extensions.ts`, and this `AGENTS.md`) — edit freely. To add or override Nest providers/modules, use `app.extensions.ts` (spread into `AppModule` after the module-wired providers, so a same-token override wins) rather than editing `app.module.ts`.
64
64
  - **assembled** (e.g. `apps/api/src/app.module.ts`, `auth.ts`) — contain `// podokit:begin:… / …:end` fenced regions that the toolkit recomputes. Edit outside the fences only; **never hand-edit inside the fences**.
65
65
  - **managed** (bootstrap/config like `main.ts`) — the toolkit may update these; avoid gratuitous edits.
66
66
 
67
67
  Useful commands:
68
68
 
69
69
  - `podo add <module>` — add a feature (auth, admin-dashboard, redis, bullmq, …). It wires itself into the app; run `podo add` with no argument to list modules.
70
+ - `podo remove <module>` — un-apply a module (inverse of add; refuses if another module needs it, keeps files you edited).
70
71
  - `podo status` / `podo diff` — see your local edits vs. what PodoKit generated.
71
72
  - `podo update` — pull in template/module improvements (3-way merges your edits; never touches owned files).
72
73
 
@@ -0,0 +1,21 @@
1
+ import type { DynamicModule, Provider, Type } from "@nestjs/common";
2
+
3
+ // Owned extension slot for the Nest app. This file is yours — PodoKit never
4
+ // writes to it, so `podo update` leaves it alone. Use it to add your own modules
5
+ // or providers, or to override a PodoKit-provided provider without editing
6
+ // managed code. Because these are spread in *after* the module-wired providers,
7
+ // an override here takes precedence.
8
+ //
9
+ // Example — override a provider with your own implementation:
10
+ // import { SomeService } from "./some/some.service";
11
+ // import { MyService } from "./my-service";
12
+ // export const extensionProviders: Provider[] = [
13
+ // { provide: SomeService, useClass: MyService },
14
+ // ];
15
+
16
+ /** Extra modules to import into AppModule (or DynamicModule overrides). */
17
+ export const extensionImports: (Type | DynamicModule)[] = [];
18
+
19
+ /** Extra or overriding providers for AppModule. Same-token providers listed here
20
+ * win over the ones PodoKit modules registered. */
21
+ export const extensionProviders: Provider[] = [];
@@ -5,6 +5,7 @@ import { validateEnv } from "./config/env.validation";
5
5
  import { dataSourceOptions } from "./database/data-source";
6
6
  import { HealthModule } from "./health/health.module";
7
7
  import { TodosModule } from "./todos/todos.module";
8
+ import { extensionImports, extensionProviders } from "./app.extensions";
8
9
  // podokit:begin:imports
9
10
  // podokit:end:imports
10
11
 
@@ -16,10 +17,12 @@ import { TodosModule } from "./todos/todos.module";
16
17
  TodosModule,
17
18
  // podokit:begin:module-imports
18
19
  // podokit:end:module-imports
20
+ ...extensionImports,
19
21
  ],
20
22
  providers: [
21
23
  // podokit:begin:providers
22
24
  // podokit:end:providers
25
+ ...extensionProviders,
23
26
  ],
24
27
  })
25
28
  export class AppModule {}
@@ -3,8 +3,14 @@ import tailwindcss from "@tailwindcss/vite";
3
3
  import { defineConfig } from "vite";
4
4
 
5
5
  // Inside the dev container (compose.dev.yaml sets VITE_DOCKER=1): bind to all
6
- // interfaces, allow the Traefik hostname, and route HMR through Traefik on :80.
6
+ // interfaces, allow the Traefik hostname, and route HMR's WebSocket back through
7
+ // Traefik. The browser reaches the app at Traefik's published host port, so the
8
+ // HMR client must target that same port — otherwise the ws connects to :80,
9
+ // fails, and Vite silently falls back to a full page reload on every change
10
+ // (which wipes in-progress form input). compose.dev.yaml injects
11
+ // VITE_HMR_CLIENT_PORT to match the published Traefik port (default 80).
7
12
  const inDocker = process.env.VITE_DOCKER === "1";
13
+ const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT) || 80;
8
14
 
9
15
  export default defineConfig({
10
16
  plugins: [tailwindcss(), sveltekit()],
@@ -13,7 +19,7 @@ export default defineConfig({
13
19
  host: true,
14
20
  port: 5001,
15
21
  allowedHosts: true,
16
- hmr: { clientPort: 80 },
22
+ hmr: { clientPort: hmrClientPort },
17
23
  }
18
24
  : undefined,
19
25
  });
@@ -4,7 +4,10 @@
4
4
  # docker compose -f compose.dev.yaml --profile cache \
5
5
  # --profile storage --profile queue watch # with modules
6
6
  #
7
- # Only Traefik binds a host port (80). The app is at http://app.localhost.
7
+ # Only Traefik binds a host port (default 80). The app is at http://app.localhost.
8
+ # Set TRAEFIK_PORT to publish on another port (e.g. TRAEFIK_PORT=8001) so several
9
+ # containerized stacks can run at once without colliding — the web container reads
10
+ # the same value as VITE_HMR_CLIENT_PORT so Vite HMR follows automatically.
8
11
  # api/postgres/redis/minio have NO published ports — they talk by service name
9
12
  # on the internal network, so this stack never collides with other projects.
10
13
  #
@@ -29,8 +32,8 @@ services:
29
32
  - --api.dashboard=true
30
33
  - --api.insecure=true
31
34
  ports:
32
- - "80:80"
33
- - "127.0.0.1:8080:8080" # Traefik dashboard (dev only)
35
+ - "${TRAEFIK_PORT:-80}:80"
36
+ - "127.0.0.1:${TRAEFIK_DASHBOARD_PORT:-8080}:8080" # Traefik dashboard (dev only)
34
37
  volumes:
35
38
  - ./infra/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
36
39
  networks: [podokit]
@@ -45,6 +48,10 @@ services:
45
48
  environment:
46
49
  - VITE_DOCKER=1
47
50
  - BACKEND_INTERNAL_URL=http://api:5002
51
+ # Match Traefik's published port so HMR's WebSocket targets the right port
52
+ # (default 80). Without this, HMR connects to :80 and a non-80 stack falls
53
+ # back to a full page reload on every edit.
54
+ - VITE_HMR_CLIENT_PORT=${TRAEFIK_PORT:-80}
48
55
  depends_on: [api]
49
56
  networks: [podokit]
50
57
  develop:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {