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