@podosoft/podokit 0.8.0 → 0.10.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 (84) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +3 -1
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  16. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  18. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +3 -1
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/+layout.server.ts +17 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -680
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.server.ts +10 -1
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +30 -11
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +96 -14
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +1 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.server.ts +11 -0
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +122 -0
  45. package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +106 -0
  46. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  47. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +74 -0
  49. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  50. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
  51. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +11 -0
  52. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
  53. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  54. package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +23 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +41 -0
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +12 -8
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +35 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +63 -0
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +23 -7
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +137 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/verify-email.ui.spec.ts +17 -0
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  64. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  65. package/dist/templates/modules/auth/files/apps/api/src/account/account.controller.ts +8 -0
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/two-factor-required.guard.ts +68 -0
  67. package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +5 -1
  68. package/dist/templates/modules/auth/module.manifest.json +10 -0
  69. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  70. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  71. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  72. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  73. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  74. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  75. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  76. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  77. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
  78. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  79. package/dist/templates/todo/dot-gitignore +6 -0
  80. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  81. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  82. package/dist/update.js +74 -8
  83. package/package.json +1 -1
  84. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
package/dist/add.d.ts CHANGED
@@ -26,6 +26,9 @@ export interface ModuleManifest {
26
26
  * they survive lock recompute. Use for public presentation pages a consumer
27
27
  * restyles, while keeping the module's `$lib` logic managed. */
28
28
  ownedGlobs?: string[];
29
+ /** Paths that an existing app may explicitly hand back to this module with
30
+ * `podo add --adopt`. Broad app-owned route globs are never removed. */
31
+ managedGlobs?: string[];
29
32
  }
30
33
  export interface AddOptions {
31
34
  projectRoot: string;
@@ -33,6 +36,8 @@ export interface AddOptions {
33
36
  modulesDir: string;
34
37
  /** PodoKit version stamped into the lockfile. Defaults to the CLI version. */
35
38
  podokitVersion?: string;
39
+ /** Adopt colliding files covered by the module's managedGlobs. */
40
+ adopt?: boolean;
36
41
  }
37
42
  export interface AddResult {
38
43
  module: string;
@@ -41,6 +46,18 @@ export interface AddResult {
41
46
  added: string[];
42
47
  /** ownedGlobs declared by this module and every module it pulled in. */
43
48
  ownedGlobs: string[];
49
+ /** Existing owned presentation files intentionally left untouched. */
50
+ preserved: string[];
51
+ /** Paths explicitly adopted as module-managed files. */
52
+ adopted: string[];
53
+ /** Files shipped, merged, or injected while applying the module. */
54
+ touched: string[];
55
+ }
56
+ export interface ResolvedModule {
57
+ dir: string;
58
+ name: string;
59
+ packageName?: string;
60
+ moduleVersion?: string;
44
61
  }
45
62
  /**
46
63
  * Resolve a module's directory by name: first the bundled `modulesDir`, then a
@@ -48,6 +65,9 @@ export interface AddResult {
48
65
  * project (or a fully-qualified `@scope/pkg` name). Returns null if not found.
49
66
  */
50
67
  export declare function resolveModuleDir(name: string, modulesDir: string, projectRoot: string): string | null;
68
+ /** Resolve a module and capture the package identity needed to replay external
69
+ * modules during `podo update`. */
70
+ export declare function resolveModule(name: string, modulesDir: string, projectRoot: string): ResolvedModule | null;
51
71
  /** List modules available to a project: the bundled ones under `modulesDir` plus,
52
72
  * when `projectRoot` is given, any `@podosoft/podokit-module-*` packages installed
53
73
  * in it. Bundled names take precedence on a clash. */
package/dist/add.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SUPPORTED_MANIFEST_VERSION = void 0;
4
4
  exports.resolveModuleDir = resolveModuleDir;
5
+ exports.resolveModule = resolveModule;
5
6
  exports.listModules = listModules;
6
7
  exports.addModule = addModule;
7
8
  const node_fs_1 = require("node:fs");
@@ -31,6 +32,27 @@ function resolveModuleDir(name, modulesDir, projectRoot) {
31
32
  return null;
32
33
  }
33
34
  }
35
+ /** Resolve a module and capture the package identity needed to replay external
36
+ * modules during `podo update`. */
37
+ function resolveModule(name, modulesDir, projectRoot) {
38
+ const dir = resolveModuleDir(name, modulesDir, projectRoot);
39
+ if (!dir)
40
+ return null;
41
+ const manifest = readManifest(dir);
42
+ const bundled = (0, node_path_1.join)(modulesDir, manifest.name);
43
+ if (dir === bundled)
44
+ return { dir, name: manifest.name };
45
+ const packageFile = (0, node_path_1.join)(dir, "package.json");
46
+ if (!(0, node_fs_1.existsSync)(packageFile))
47
+ return { dir, name: manifest.name };
48
+ const pkg = readJson(packageFile);
49
+ return {
50
+ dir,
51
+ name: manifest.name,
52
+ packageName: typeof pkg.name === "string" ? pkg.name : undefined,
53
+ moduleVersion: typeof pkg.version === "string" ? pkg.version : undefined,
54
+ };
55
+ }
34
56
  function assertManifestVersion(name, manifest) {
35
57
  if ((manifest.manifestVersion ?? exports.SUPPORTED_MANIFEST_VERSION) > exports.SUPPORTED_MANIFEST_VERSION) {
36
58
  throw new Error(`Module "${name}" needs a newer PodoKit (manifest v${manifest.manifestVersion}, this CLI supports v${exports.SUPPORTED_MANIFEST_VERSION}). Update @podosoft/podokit.`);
@@ -105,18 +127,42 @@ function isApplied(projectRoot, modulesDir, module) {
105
127
  * and inject wiring at markers. Missing required modules are added first.
106
128
  */
107
129
  function addModule(options) {
108
- const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set());
130
+ const previousLock = (0, lockfile_1.readFilesLock)(options.projectRoot);
131
+ const cleanPaths = previousLock
132
+ ? Object.entries(previousLock.files)
133
+ .filter(([path, entry]) => {
134
+ const target = (0, node_path_1.join)(options.projectRoot, path);
135
+ return (0, node_fs_1.existsSync)(target) && (0, podokit_template_engine_1.hashContent)((0, node_fs_1.readFileSync)(target)) === entry.outHash;
136
+ })
137
+ .map(([path]) => path)
138
+ : [];
139
+ const result = applyModule(options.projectRoot, options.module, options.modulesDir, new Set(), options.adopt ?? false);
140
+ if (options.adopt && result.adopted.length) {
141
+ releaseOwnedPaths(options.projectRoot, result.adopted);
142
+ }
109
143
  // Record the module (and any auto-added requirements), fold in the modules'
110
144
  // declared ownedGlobs, and refresh the lock.
111
- (0, lockfile_1.recordModules)(options.projectRoot, [...result.added, result.module], options.podokitVersion, result.ownedGlobs);
145
+ const records = [...result.added, result.module]
146
+ .map((name) => resolveModule(name, options.modulesDir, options.projectRoot))
147
+ .filter((module) => module !== null)
148
+ .map(moduleRecord);
149
+ (0, lockfile_1.recordModules)(options.projectRoot, records, options.podokitVersion, result.ownedGlobs, previousLock
150
+ ? {
151
+ previous: previousLock,
152
+ cleanPaths,
153
+ modulePaths: [...new Set(result.touched)],
154
+ adoptedPaths: result.adopted,
155
+ }
156
+ : undefined);
112
157
  return result;
113
158
  }
114
- function applyModule(projectRoot, module, modulesDir, applied) {
115
- const moduleDir = resolveModuleDir(module, modulesDir, projectRoot);
116
- if (!moduleDir) {
159
+ function applyModule(projectRoot, module, modulesDir, applied, adopt) {
160
+ const resolved = resolveModule(module, modulesDir, projectRoot);
161
+ if (!resolved) {
117
162
  const available = listModules(modulesDir, projectRoot).map((m) => m.name);
118
163
  throw new Error(`Unknown module "${module}".${available.length ? ` Available: ${available.join(", ")}.` : ""}`);
119
164
  }
165
+ const moduleDir = resolved.dir;
120
166
  const manifest = readManifest(moduleDir);
121
167
  assertManifestVersion(module, manifest);
122
168
  const appPkgPath = (0, node_path_1.join)(projectRoot, "apps", manifest.targetApp, "package.json");
@@ -127,19 +173,34 @@ function applyModule(projectRoot, module, modulesDir, applied) {
127
173
  // 0) apply required modules first (auto-add if missing)
128
174
  const added = [];
129
175
  const ownedGlobs = [...(manifest.ownedGlobs ?? [])];
176
+ const preserved = [];
177
+ const adopted = [];
178
+ const touched = [];
130
179
  for (const required of manifest.requires ?? []) {
131
180
  if (applied.has(required) || isApplied(projectRoot, modulesDir, required))
132
181
  continue;
133
- const result = applyModule(projectRoot, required, modulesDir, applied);
182
+ const result = applyModule(projectRoot, required, modulesDir, applied, adopt);
134
183
  added.push(required, ...result.added);
135
184
  ownedGlobs.push(...result.ownedGlobs);
185
+ preserved.push(...result.preserved);
186
+ adopted.push(...result.adopted);
187
+ touched.push(...result.touched);
136
188
  }
137
189
  const appName = projectName(projectRoot);
138
190
  const vars = { projectName: appName };
139
191
  // 1) overlay files
140
192
  const filesDir = (0, node_path_1.join)(moduleDir, "files");
141
193
  if ((0, node_fs_1.existsSync)(filesDir)) {
142
- (0, podokit_template_engine_1.copyTemplate)(filesDir, projectRoot, vars);
194
+ const copied = copyModuleFiles({
195
+ filesDir,
196
+ projectRoot,
197
+ vars,
198
+ managedGlobs: manifest.managedGlobs ?? [],
199
+ adopt,
200
+ });
201
+ preserved.push(...copied.preserved);
202
+ adopted.push(...copied.adopted);
203
+ touched.push(...copied.touched);
143
204
  }
144
205
  // 2) merge dependencies and scripts into the target app
145
206
  if (manifest.dependencies || manifest.devDependencies || manifest.scripts) {
@@ -152,10 +213,12 @@ function applyModule(projectRoot, module, modulesDir, applied) {
152
213
  overlay.scripts = manifest.scripts;
153
214
  const merged = (0, podokit_template_engine_1.mergePackageJson)(readJson(appPkgPath), overlay);
154
215
  (0, node_fs_1.writeFileSync)(appPkgPath, `${JSON.stringify(merged, null, 2)}\n`);
216
+ touched.push(`apps/${manifest.targetApp}/package.json`);
155
217
  }
156
218
  // 3) append env example lines
157
219
  if (manifest.env?.length) {
158
220
  appendEnv(projectRoot, manifest.env);
221
+ touched.push(".env.example");
159
222
  }
160
223
  // 4) inject wiring at markers
161
224
  for (const injection of manifest.inject ?? []) {
@@ -169,7 +232,57 @@ function applyModule(projectRoot, module, modulesDir, applied) {
169
232
  if (injection.optional && !content.includes(injection.marker))
170
233
  continue;
171
234
  (0, node_fs_1.writeFileSync)(target, (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text));
235
+ touched.push(injection.file);
172
236
  }
173
237
  const instructions = (manifest.instructions ?? []).map((line) => line.replace(/<app>/g, appName));
174
- return { module, instructions, added, ownedGlobs };
238
+ return { module: manifest.name, instructions, added, ownedGlobs, preserved, adopted, touched };
239
+ }
240
+ function moduleRecord(module) {
241
+ return {
242
+ name: module.name,
243
+ packageName: module.packageName,
244
+ moduleVersion: module.moduleVersion,
245
+ };
246
+ }
247
+ /** Copy a module overlay without clobbering app-owned presentation files. */
248
+ function copyModuleFiles(options) {
249
+ const tree = (0, podokit_template_engine_1.renderTemplate)(options.filesDir, options.vars);
250
+ const lock = (0, lockfile_1.readFilesLock)(options.projectRoot);
251
+ const preserved = [];
252
+ const adopted = [];
253
+ const touched = [];
254
+ for (const [rel, file] of tree) {
255
+ touched.push(rel);
256
+ const target = (0, node_path_1.join)(options.projectRoot, rel);
257
+ const existed = (0, node_fs_1.existsSync)(target);
258
+ const entry = lock?.files[rel];
259
+ const canAdopt = options.managedGlobs.some((glob) => (0, lockfile_1.matchGlob)(rel, glob));
260
+ if (existed && entry?.tier === "owned" && !(options.adopt && canAdopt)) {
261
+ preserved.push(rel);
262
+ continue;
263
+ }
264
+ if (existed && !entry && !(options.adopt && canAdopt)) {
265
+ const current = (0, node_fs_1.readFileSync)(target);
266
+ const next = Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content);
267
+ if (!current.equals(next)) {
268
+ throw new Error(`Cannot add module: ${rel} already exists outside PodoKit ownership. ` +
269
+ "Move it, or re-run with --adopt when the module declares the path managed.");
270
+ }
271
+ }
272
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(target), { recursive: true });
273
+ (0, node_fs_1.writeFileSync)(target, file.content);
274
+ if (existed && options.adopt && canAdopt)
275
+ adopted.push(rel);
276
+ }
277
+ return { preserved, adopted, touched };
278
+ }
279
+ /** Remove only exact owned paths that an explicit adoption handed back. Broad
280
+ * ownership rules such as `apps/web/src/routes/**` remain intact. */
281
+ function releaseOwnedPaths(projectRoot, adopted) {
282
+ const manifest = (0, lockfile_1.readManifest)(projectRoot);
283
+ if (!manifest)
284
+ return;
285
+ const adoptedSet = new Set(adopted);
286
+ manifest.ownedGlobs = manifest.ownedGlobs.filter((glob) => glob.includes("*") || !adoptedSet.has(glob));
287
+ (0, lockfile_1.writeManifest)(projectRoot, manifest);
175
288
  }
@@ -1,4 +1,5 @@
1
1
  import { type TemplateVars, type VfsTree } from "@podosoft/podokit-template-engine";
2
+ import type { ManifestModule } from "./lockfile";
2
3
  /**
3
4
  * Reconstruct a generated project in memory (no disk writes) from a template
4
5
  * plus an ordered module list, mirroring `create` + `add`. `podo update` uses
@@ -8,7 +9,9 @@ export interface AssembleOptions {
8
9
  templatesDir: string;
9
10
  template: string;
10
11
  answers: TemplateVars;
11
- modules: string[];
12
+ modules: (string | ManifestModule)[];
13
+ /** Generated project whose node_modules may contain external modules. */
14
+ projectRoot?: string;
12
15
  }
13
16
  /** Assemble the project tree for a template + ordered modules, in memory. */
14
17
  export declare function assembleProject(options: AssembleOptions): VfsTree;
package/dist/assemble.js CHANGED
@@ -4,6 +4,7 @@ exports.assembleProject = assembleProject;
4
4
  const node_fs_1 = require("node:fs");
5
5
  const node_path_1 = require("node:path");
6
6
  const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
7
+ const add_1 = require("./add");
7
8
  function readManifest(moduleDir) {
8
9
  return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(moduleDir, "module.manifest.json"), "utf8"));
9
10
  }
@@ -53,11 +54,44 @@ function applyModuleToTree(tree, moduleDir, vars) {
53
54
  setText(tree, target, (0, podokit_template_engine_1.insertAtMarker)(content, injection.marker, injection.text));
54
55
  }
55
56
  }
57
+ function preserveExternalPackageDependency(tree, projectRoot, packageName) {
58
+ const diskPackagePath = (0, node_path_1.join)(projectRoot, "package.json");
59
+ if (!(0, node_fs_1.existsSync)(diskPackagePath))
60
+ return;
61
+ const diskPackage = JSON.parse((0, node_fs_1.readFileSync)(diskPackagePath, "utf8"));
62
+ const rootPackage = JSON.parse(textOf(tree, "package.json") || "{}");
63
+ for (const section of ["dependencies", "devDependencies", "optionalDependencies"]) {
64
+ const diskSection = diskPackage[section];
65
+ if (!diskSection || typeof diskSection !== "object" || Array.isArray(diskSection))
66
+ continue;
67
+ const version = diskSection[packageName];
68
+ if (typeof version !== "string")
69
+ continue;
70
+ const current = rootPackage[section];
71
+ const dependencies = current && typeof current === "object" && !Array.isArray(current)
72
+ ? { ...current }
73
+ : {};
74
+ dependencies[packageName] = version;
75
+ rootPackage[section] = dependencies;
76
+ setText(tree, "package.json", `${JSON.stringify(rootPackage, null, 2)}\n`);
77
+ return;
78
+ }
79
+ }
56
80
  /** Assemble the project tree for a template + ordered modules, in memory. */
57
81
  function assembleProject(options) {
58
82
  const tree = (0, podokit_template_engine_1.renderTemplate)((0, node_path_1.join)(options.templatesDir, options.template), options.answers);
59
83
  for (const mod of options.modules) {
60
- applyModuleToTree(tree, (0, node_path_1.join)(options.templatesDir, "modules", mod), options.answers);
84
+ const name = typeof mod === "string" ? mod : (mod.packageName ?? mod.name);
85
+ const resolved = (0, add_1.resolveModule)(name, (0, node_path_1.join)(options.templatesDir, "modules"), options.projectRoot ?? options.templatesDir);
86
+ if (!resolved) {
87
+ const label = typeof mod === "string" ? mod : mod.name;
88
+ throw new Error(`Cannot resolve module "${label}" while assembling the project. ` +
89
+ "Install its package before running podo update.");
90
+ }
91
+ applyModuleToTree(tree, resolved.dir, options.answers);
92
+ if (typeof mod !== "string" && mod.packageName && options.projectRoot) {
93
+ preserveExternalPackageDependency(tree, options.projectRoot, mod.packageName);
94
+ }
61
95
  }
62
96
  return tree;
63
97
  }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ interface ParsedArgs {
8
8
  pm?: PackageManager;
9
9
  from?: string;
10
10
  apply: boolean;
11
+ adopt: boolean;
11
12
  yes: boolean;
12
13
  help: boolean;
13
14
  ai: boolean;
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ const HELP = `podo — PodoKit project generator
16
16
 
17
17
  Usage:
18
18
  podo create <name> [options]
19
- podo add <module>
19
+ podo add <module> [--adopt]
20
20
  podo remove <module> Un-apply a module (inverse of add)
21
21
  podo status Show version, modules, file tiers, and local edits
22
22
  podo diff List PodoKit-managed files you have edited
@@ -28,6 +28,7 @@ Options:
28
28
  --template <t> Template to scaffold (see below)
29
29
  --dir <path> Target directory (default: ./<name>)
30
30
  --pm <name> Package manager: npm | pnpm | yarn (default: npm)
31
+ --adopt Adopt colliding paths explicitly declared managed by a module
31
32
  --no-ai Skip AI agent guidance (AGENTS.md, CLAUDE.md, editor rules)
32
33
  -y, --yes Skip prompts and accept defaults
33
34
  -h, --help Show this help
@@ -41,7 +42,7 @@ Example:
41
42
  cd my-app && npx @podosoft/podokit add auth
42
43
  `;
43
44
  function parseArgs(argv) {
44
- const parsed = { help: false, yes: false, apply: false, ai: true };
45
+ const parsed = { help: false, yes: false, apply: false, adopt: false, ai: true };
45
46
  const positionals = [];
46
47
  for (let i = 0; i < argv.length; i += 1) {
47
48
  const arg = argv[i];
@@ -57,6 +58,9 @@ function parseArgs(argv) {
57
58
  else if (arg === "--apply") {
58
59
  parsed.apply = true;
59
60
  }
61
+ else if (arg === "--adopt") {
62
+ parsed.adopt = true;
63
+ }
60
64
  else if (arg === "--template") {
61
65
  parsed.template = argv[++i];
62
66
  }
@@ -99,11 +103,22 @@ async function main(argv) {
99
103
  return;
100
104
  }
101
105
  try {
102
- const result = (0, add_1.addModule)({ projectRoot: process.cwd(), module: moduleName, modulesDir });
106
+ const result = (0, add_1.addModule)({
107
+ projectRoot: process.cwd(),
108
+ module: moduleName,
109
+ modulesDir,
110
+ adopt: args.adopt,
111
+ });
103
112
  if (result.added.length) {
104
113
  process.stdout.write(`\nAlso added required module(s): ${result.added.join(", ")}\n`);
105
114
  }
106
115
  process.stdout.write(`\nAdded ${result.module}.\n`);
116
+ if (result.preserved.length) {
117
+ process.stdout.write(`\nPreserved app-owned presentation file(s):\n${result.preserved.map((file) => ` ${file}`).join("\n")}\n`);
118
+ }
119
+ if (result.adopted.length) {
120
+ process.stdout.write(`\nAdopted as module-managed file(s):\n${result.adopted.map((file) => ` ${file}`).join("\n")}\n`);
121
+ }
107
122
  if (result.instructions.length) {
108
123
  process.stdout.write(`\nNext steps:\n${result.instructions.map((i) => ` ${i}`).join("\n")}\n`);
109
124
  }
@@ -140,8 +155,9 @@ async function main(argv) {
140
155
  try {
141
156
  const s = (0, inspect_1.status)(process.cwd());
142
157
  const tiers = `managed ${s.tiers.managed}, assembled ${s.tiers.assembled}, owned ${s.tiers.owned}`;
158
+ const modules = s.moduleDetails.map((module) => module.version ? `${module.name}@${module.version}` : module.name);
143
159
  process.stdout.write(`PodoKit ${s.podokitVersion} (template: ${s.template}, ${s.packageManager})\n` +
144
- `Modules: ${s.modules.length ? s.modules.join(", ") : "(none)"}\n` +
160
+ `Modules: ${modules.length ? modules.join(", ") : "(none)"}\n` +
145
161
  `Files: ${tiers}\n` +
146
162
  `Edited: ${s.drifted.length} managed file(s)${s.missing.length ? `, ${s.missing.length} missing` : ""}\n` +
147
163
  (s.drifted.length ? s.drifted.map((f) => ` ~ ${f}`).join("\n") + "\n" : ""));
package/dist/inspect.d.ts CHANGED
@@ -8,6 +8,11 @@ export interface StatusReport {
8
8
  template: string;
9
9
  packageManager: string;
10
10
  modules: string[];
11
+ moduleDetails: {
12
+ name: string;
13
+ packageName?: string;
14
+ version?: string;
15
+ }[];
11
16
  tiers: Record<Tier, number>;
12
17
  drifted: string[];
13
18
  missing: string[];
package/dist/inspect.js CHANGED
@@ -29,6 +29,11 @@ function status(projectRoot) {
29
29
  template: manifest.template,
30
30
  packageManager: manifest.packageManager,
31
31
  modules: manifest.modules.map((m) => m.name),
32
+ moduleDetails: manifest.modules.map((m) => ({
33
+ name: m.name,
34
+ packageName: m.packageName,
35
+ version: m.moduleVersion,
36
+ })),
32
37
  tiers,
33
38
  drifted,
34
39
  missing,
@@ -12,6 +12,15 @@ export interface ManifestModule {
12
12
  order: number;
13
13
  /** PodoKit version that applied the module. */
14
14
  addedWith: string;
15
+ /** npm package backing an external module. Absent for bundled modules. */
16
+ packageName?: string;
17
+ /** Installed external-module version last applied to this project. */
18
+ moduleVersion?: string;
19
+ }
20
+ export interface ManifestModuleInput {
21
+ name: string;
22
+ packageName?: string;
23
+ moduleVersion?: string;
15
24
  }
16
25
  export interface PodokitManifest {
17
26
  schemaVersion: number;
@@ -33,6 +42,16 @@ export interface FilesLock {
33
42
  schemaVersion: number;
34
43
  files: Record<string, FileEntry>;
35
44
  }
45
+ export interface RecordModulesLockSnapshot {
46
+ /** Lock state captured before module files are written. */
47
+ previous: FilesLock;
48
+ /** Previously locked paths whose working bytes still matched their baseline. */
49
+ cleanPaths: string[];
50
+ /** Files shipped, merged, or injected by the module application. */
51
+ modulePaths: string[];
52
+ /** Owned paths explicitly handed back with `podo add --adopt`. */
53
+ adoptedPaths: string[];
54
+ }
36
55
  /** Default user-owned globs seeded into a new project. See ADR-0010. */
37
56
  export declare const DEFAULT_OWNED_GLOBS: string[];
38
57
  export declare function manifestPath(projectRoot: string): string;
@@ -82,4 +101,4 @@ export declare function mergeOwnedGlobs(existing: string[], add: string[]): stri
82
101
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
83
102
  * survive every future recompute. No-op on projects without a manifest.
84
103
  */
85
- export declare function recordModules(projectRoot: string, moduleNames: string[], version?: string, ownedGlobs?: string[]): void;
104
+ export declare function recordModules(projectRoot: string, moduleInputs: (string | ManifestModuleInput)[], version?: string, ownedGlobs?: string[], lockSnapshot?: RecordModulesLockSnapshot): void;
package/dist/lockfile.js CHANGED
@@ -209,20 +209,62 @@ function mergeOwnedGlobs(existing, add) {
209
209
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
210
210
  * survive every future recompute. No-op on projects without a manifest.
211
211
  */
212
- function recordModules(projectRoot, moduleNames, version, ownedGlobs) {
212
+ function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot) {
213
213
  const manifest = readManifest(projectRoot);
214
214
  if (!manifest)
215
215
  return;
216
216
  const stampedWith = version ?? podokitVersion();
217
217
  const known = new Set(manifest.modules.map((m) => m.name));
218
- for (const name of moduleNames) {
219
- if (known.has(name))
218
+ for (const input of moduleInputs) {
219
+ const module = typeof input === "string" ? { name: input } : input;
220
+ const existing = manifest.modules.find((entry) => entry.name === module.name);
221
+ if (existing) {
222
+ existing.packageName = module.packageName ?? existing.packageName;
223
+ existing.moduleVersion = module.moduleVersion ?? existing.moduleVersion;
220
224
  continue;
221
- manifest.modules.push({ name, order: manifest.modules.length, addedWith: stampedWith });
222
- known.add(name);
225
+ }
226
+ manifest.modules.push({
227
+ name: module.name,
228
+ order: manifest.modules.length,
229
+ addedWith: stampedWith,
230
+ packageName: module.packageName,
231
+ moduleVersion: module.moduleVersion,
232
+ });
233
+ known.add(module.name);
223
234
  }
224
235
  if (ownedGlobs?.length)
225
236
  manifest.ownedGlobs = mergeOwnedGlobs(manifest.ownedGlobs, ownedGlobs);
226
237
  writeManifest(projectRoot, manifest);
227
- writeFilesLock(projectRoot, computeFilesLock(projectRoot, manifest.ownedGlobs));
238
+ const computed = computeFilesLock(projectRoot, manifest.ownedGlobs);
239
+ if (!lockSnapshot) {
240
+ writeFilesLock(projectRoot, computed);
241
+ return;
242
+ }
243
+ const clean = new Set(lockSnapshot.cleanPaths);
244
+ const modulePaths = new Set(lockSnapshot.modulePaths);
245
+ const adopted = new Set(lockSnapshot.adoptedPaths);
246
+ const files = {};
247
+ for (const [path, previous] of Object.entries(lockSnapshot.previous.files)) {
248
+ const current = computed.files[path];
249
+ if (!current) {
250
+ // Preserve missing-file drift that existed before the add operation.
251
+ files[path] = previous;
252
+ continue;
253
+ }
254
+ if (current.tier === "owned" || adopted.has(path) || clean.has(path)) {
255
+ files[path] = current;
256
+ }
257
+ else {
258
+ // Adding a module must not teach the lock that pre-existing local edits
259
+ // are generated output. Keep the old baseline so update stays safe.
260
+ files[path] = previous;
261
+ }
262
+ }
263
+ for (const path of modulePaths) {
264
+ const current = computed.files[path];
265
+ if (!current || files[path])
266
+ continue;
267
+ files[path] = current;
268
+ }
269
+ writeFilesLock(projectRoot, { schemaVersion: exports.LOCK_SCHEMA_VERSION, files });
228
270
  }
package/dist/remove.js CHANGED
@@ -42,7 +42,8 @@ function removeModule(options) {
42
42
  const installed = manifest.modules.map((m) => m.name).join(", ") || "(none)";
43
43
  throw new Error(`Module "${module}" is not installed. Installed: ${installed}.`);
44
44
  }
45
- const moduleDir = (0, add_1.resolveModuleDir)(module, modulesDir, projectRoot);
45
+ const installedModule = manifest.modules.find((entry) => entry.name === module);
46
+ const moduleDir = (0, add_1.resolveModuleDir)(installedModule?.packageName ?? module, modulesDir, projectRoot);
46
47
  if (!moduleDir) {
47
48
  throw new Error(`Cannot resolve module "${module}" to un-apply it. Is it still installed/bundled?`);
48
49
  }
@@ -52,7 +53,7 @@ function removeModule(options) {
52
53
  const others = manifest.modules
53
54
  .filter((m) => m.name !== module)
54
55
  .map((m) => {
55
- const dir = (0, add_1.resolveModuleDir)(m.name, modulesDir, projectRoot);
56
+ const dir = (0, add_1.resolveModuleDir)(m.packageName ?? m.name, modulesDir, projectRoot);
56
57
  return dir ? { name: m.name, dir, manifest: readModuleManifest(dir) } : null;
57
58
  })
58
59
  .filter((m) => m !== null);
@@ -23,6 +23,14 @@
23
23
  --border: oklch(0.92 0.004 286.32);
24
24
  --input: oklch(0.92 0.004 286.32);
25
25
  --ring: oklch(0.705 0.015 286.067);
26
+ --sidebar: oklch(0.985 0 0);
27
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
28
+ --sidebar-primary: oklch(0.21 0.006 285.885);
29
+ --sidebar-primary-foreground: oklch(0.985 0 0);
30
+ --sidebar-accent: oklch(0.967 0.001 286.375);
31
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
32
+ --sidebar-border: oklch(0.92 0.004 286.32);
33
+ --sidebar-ring: oklch(0.705 0.015 286.067);
26
34
  }
27
35
 
28
36
  .dark {
@@ -44,6 +52,14 @@
44
52
  --border: oklch(1 0 0 / 10%);
45
53
  --input: oklch(1 0 0 / 15%);
46
54
  --ring: oklch(0.552 0.016 285.938);
55
+ --sidebar: oklch(0.21 0.006 285.885);
56
+ --sidebar-foreground: oklch(0.985 0 0);
57
+ --sidebar-primary: oklch(0.92 0.004 286.32);
58
+ --sidebar-primary-foreground: oklch(0.21 0.006 285.885);
59
+ --sidebar-accent: oklch(0.274 0.006 286.033);
60
+ --sidebar-accent-foreground: oklch(0.985 0 0);
61
+ --sidebar-border: oklch(1 0 0 / 10%);
62
+ --sidebar-ring: oklch(0.552 0.016 285.938);
47
63
  }
48
64
 
49
65
  @theme inline {
@@ -69,6 +85,14 @@
69
85
  --color-border: var(--border);
70
86
  --color-input: var(--input);
71
87
  --color-ring: var(--ring);
88
+ --color-sidebar: var(--sidebar);
89
+ --color-sidebar-foreground: var(--sidebar-foreground);
90
+ --color-sidebar-primary: var(--sidebar-primary);
91
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
92
+ --color-sidebar-accent: var(--sidebar-accent);
93
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
94
+ --color-sidebar-border: var(--sidebar-border);
95
+ --color-sidebar-ring: var(--sidebar-ring);
72
96
  }
73
97
 
74
98
  @layer base {
@@ -79,3 +103,21 @@
79
103
  @apply bg-background text-foreground;
80
104
  }
81
105
  }
106
+
107
+ /* shadcn-svelte sidebar active-state correction.
108
+ * Upstream (shadcn-svelte registry, `nova` style) styles the active item with
109
+ * the `data-active:` Tailwind variant, which matches attribute PRESENCE
110
+ * (`[data-active]`). The menu buttons always render `data-active="true|false"`,
111
+ * so the active look (accent background, accent text, medium weight) leaks onto
112
+ * EVERY item — nearly invisible in the default palette but glaring once a theme
113
+ * raises the contrast. We keep `ui/sidebar/*` as a pristine mirror of upstream
114
+ * (so `shadcn-svelte` updates apply cleanly) and correct the behaviour here:
115
+ * neutralize the resting state of non-active items, leaving hover/press feedback
116
+ * (their `:hover`/`:active` rules) untouched. Drop this once shadcn-svelte ships
117
+ * value-matching `data-[active=true]:` (canonical shadcn/ui already does). */
118
+ [data-sidebar="menu-button"][data-active="false"]:not(:hover):not(:active),
119
+ [data-sidebar="menu-sub-button"][data-active="false"]:not(:hover):not(:active) {
120
+ background-color: transparent;
121
+ color: var(--sidebar-foreground);
122
+ font-weight: 400;
123
+ }
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ // Modules can replace this managed component with global site behavior while
3
+ // the surrounding route layout remains application-owned.
4
+ import { ModeWatcher } from "mode-watcher";
5
+ </script>
6
+
7
+ <ModeWatcher />
@@ -37,7 +37,9 @@ export async function proxyRequest(
37
37
  const upstream = await fetch(targetUrl, {
38
38
  method: request.method,
39
39
  headers,
40
- body: hasBody ? await request.text() : undefined,
40
+ // Preserve multipart uploads and other binary bodies byte-for-byte. Reading
41
+ // them as text corrupts bytes that are not valid UTF-8 before forwarding.
42
+ body: hasBody ? await request.arrayBuffer() : undefined,
41
43
  redirect: "manual",
42
44
  });
43
45
 
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">
2
2
  import "../app.css";
3
- import { ModeWatcher } from "mode-watcher";
3
+ import SiteRuntime from "$lib/components/site-runtime.svelte";
4
4
 
5
5
  let { children } = $props();
6
6
  </script>
7
7
 
8
- <ModeWatcher />
8
+ <SiteRuntime />
9
9
  {@render children()}
@@ -12,3 +12,9 @@ build/
12
12
  tests/playwright/
13
13
  tests/playwright-report/
14
14
  tests/test-results/
15
+ playwright/.auth/
16
+ playwright-report/
17
+ test-results/
18
+
19
+ # local performance reports
20
+ output/lighthouse/