@podosoft/podokit 0.7.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.
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
@@ -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());
@@ -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
+ }
@@ -66,6 +66,7 @@ in `.podokit/files.lock` — respect it:
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
 
@@ -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:
@@ -67,6 +67,7 @@ in `.podokit/files.lock` — respect it:
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
 
@@ -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.7.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": {