diff --git a/.changeset/quiet-wolves-connect.md b/.changeset/quiet-wolves-connect.md new file mode 100644 index 00000000..fd927d6a --- /dev/null +++ b/.changeset/quiet-wolves-connect.md @@ -0,0 +1,5 @@ +--- +"@podosoft/podokit": patch +--- + +Add a detached `podo dev up` lifecycle command, restore shared gateway routing when refreshing generated development apps, remove stale routes when a project's local hostname changes, and seed newly introduced owned files without overwriting existing application paths. diff --git a/docs/development.md b/docs/development.md index 59b72ee0..c538b5be 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,6 +109,8 @@ replace it with `npx @podosoft/podokit`, for example ```bash cd /tmp/myapp podo dev watch # core stack +# For a detached stack without source watching: +podo dev up -d # installed modules automatically enable cache/storage/queue as needed # first run only — create the tables (in the api container): podo dev exec api \ @@ -140,7 +142,8 @@ What you get: - **Live edits.** `podo dev watch` delegates to Compose Watch. The web has instant Vite HMR; an **API** source change restarts the api service (~5s) — the stable approach for NestJS in a container (its in-process watcher doesn't reliably respawn). -- **Lifecycle helpers.** Use `podo dev ps`, `podo dev logs`, `podo dev exec`, and +- **Lifecycle helpers.** Use `podo dev up -d` for a detached stack and `podo dev ps`, + `podo dev logs`, `podo dev exec`, and `podo dev down`. `down` automatically activates every Compose profile so it also removes optional services that were started by an earlier `watch` command. The last project removed also removes the shared gateway and network. diff --git a/packages/cli/src/dev.test.ts b/packages/cli/src/dev.test.ts index cf132d25..d3e68bbc 100644 --- a/packages/cli/src/dev.test.ts +++ b/packages/cli/src/dev.test.ts @@ -177,6 +177,55 @@ describe("PodoKit development gateway", () => { expect(calls.some(({ args }) => args[0] === "rm" && args.includes("podokit-dev-gateway"))).toBe(true); }); + it("starts a detached stack through the shared gateway", () => { + const root = project(); + const devHome = temporaryDirectory("podokit-dev-home-"); + process.env.PODOKIT_DEV_HOME = devHome; + const calls: string[][] = []; + let networkExists = false; + let gatewayExists = false; + const runner: CommandRunner = (_command, args) => { + calls.push(args); + if (args[0] === "info") return { status: 0, stdout: "27.0.0\n", stderr: "" }; + if (args[0] === "network" && args[1] === "inspect") { + return { status: networkExists ? 0 : 1, stdout: "", stderr: "" }; + } + if (args[0] === "network" && args[1] === "create") { + networkExists = true; + return { status: 0, stdout: "created\n", stderr: "" }; + } + if (args[0] === "inspect") { + return { + status: gatewayExists ? 0 : 1, + stdout: gatewayExists ? "1 true\n" : "", + stderr: "", + }; + } + if (args[0] === "run") gatewayExists = true; + return { status: 0, stdout: "", stderr: "" }; + }; + + runDevCommand(root, "up", ["-d", "--build"], runner); + + const upCall = calls.find((args) => args.includes("up")); + expect(upCall?.slice(-3)).toEqual(["up", "-d", "--build"]); + const originalRuntime = resolveDevRuntime(root); + expect(readFileSync(originalRuntime.runtimeComposePath, "utf8")).toContain( + "podokit-dev-gateway", + ); + expect(calls.filter((args) => args[0] === "run")).toHaveLength(1); + + writeFileSync( + join(root, ".podokit", "dev.json"), + JSON.stringify({ schemaVersion: 1, hostname: "renamed.localhost" }), + ); + runDevCommand(root, "up", ["-d"], runner); + const renamedRuntime = resolveDevRuntime(root); + expect(existsSync(join(devHome, "projects", `${originalRuntime.routeId}.json`))).toBe(false); + expect(existsSync(join(devHome, "routes", `${originalRuntime.routeId}.yml`))).toBe(false); + expect(existsSync(join(devHome, "projects", `${renamedRuntime.routeId}.json`))).toBe(true); + }); + it("activates every compose profile when stopping a project", () => { const root = project(); const devHome = temporaryDirectory("podokit-dev-home-"); diff --git a/packages/cli/src/dev.ts b/packages/cli/src/dev.ts index 46d302c2..359e7861 100644 --- a/packages/cli/src/dev.ts +++ b/packages/cli/src/dev.ts @@ -211,7 +211,8 @@ function writeRuntimeFiles(runtime: DevRuntime): void { } function registerRoute(runtime: DevRuntime): void { - const collision = activeEntries().find( + const entries = activeEntries(); + const collision = entries.find( (entry) => entry.hostname === runtime.config.hostname && entry.projectRoot !== runtime.projectRoot, ); if (collision) { @@ -219,6 +220,12 @@ function registerRoute(runtime: DevRuntime): void { `Development hostname ${runtime.config.hostname} is already registered by ${collision.projectRoot}. Change .podokit/dev.json or run podo dev down there.`, ); } + for (const stale of entries.filter( + (entry) => entry.projectRoot === runtime.projectRoot && entry.routeId !== runtime.routeId, + )) { + rmSync(join(registryDirectory(), `${stale.routeId}.json`), { force: true }); + rmSync(join(routesDirectory(), `${stale.routeId}.yml`), { force: true }); + } mkdirSync(registryDirectory(), { recursive: true }); mkdirSync(routesDirectory(), { recursive: true }); writeFileSync(routePath(runtime), renderRoute(runtime)); @@ -320,7 +327,7 @@ function runCompose(runtime: DevRuntime, args: string[], runner: CommandRunner): } function composeCommand( - action: "down" | "exec" | "logs" | "ps" | "watch", + action: "down" | "exec" | "logs" | "ps" | "up" | "watch", args: string[], installedProfiles: string[], ): string[] { @@ -391,6 +398,22 @@ export function runDevCommand( return; } + if (action === "up") { + ensureNetwork(runner); + registerRoute(runtime); + try { + ensureGateway(runner); + process.stdout.write(`Development URL: http://${runtime.config.hostname}\n`); + if (runtime.config.publicUrl) process.stdout.write(`Public OAuth URL: ${runtime.config.publicUrl}\n`); + runCompose(runtime, composeCommand("up", args, profiles), runner); + } catch (error) { + unregisterRoute(runtime); + stopGatewayWhenUnused(runner); + throw error; + } + return; + } + if (action === "down") { runCompose(runtime, composeCommand("down", args, profiles), runner); unregisterRoute(runtime); @@ -403,5 +426,5 @@ export function runDevCommand( return; } - throw new Error("Usage: podo dev [docker compose options]"); + throw new Error("Usage: podo dev [docker compose options]"); } diff --git a/packages/cli/src/update.test.ts b/packages/cli/src/update.test.ts index a4754082..1fd51162 100644 --- a/packages/cli/src/update.test.ts +++ b/packages/cli/src/update.test.ts @@ -309,6 +309,53 @@ describe("applyUpdate", () => { return templates; } + it("adds newly introduced owned template and module seeds once", () => { + const oldTemplates = oldTemplatesCopy(); + const extension = "apps/api/src/app.extensions.ts"; + const settingsComponent = + "apps/web/src/routes/(admin)/admin/settings/general-settings.svelte"; + rmSync(join(oldTemplates, "fullstack-nest-svelte", extension)); + rmSync(join(oldTemplates, "modules/admin-dashboard/files", settingsComponent)); + + const project = join(tmp(), "app"); + create({ + name: "app", + template: "fullstack-nest-svelte", + templatesDir: oldTemplates, + targetDir: project, + }); + addModule({ + projectRoot: project, + module: "admin-dashboard", + modulesDir: join(oldTemplates, "modules"), + }); + + expect(existsSync(join(project, extension))).toBe(false); + expect(existsSync(join(project, settingsComponent))).toBe(false); + const plan = planUpdate(project, REPO_TEMPLATES); + expect(plan.changes.find((change) => change.path === extension)).toMatchObject({ + action: "add", + tier: "owned", + }); + expect(plan.changes.find((change) => change.path === settingsComponent)).toMatchObject({ + action: "add", + tier: "owned", + }); + + const result = applyUpdate(project, REPO_TEMPLATES); + expect(result.written).toEqual(expect.arrayContaining([extension, settingsComponent])); + expect(readFilesLock(project)?.files[extension]?.tier).toBe("owned"); + expect(readFilesLock(project)?.files[settingsComponent]?.tier).toBe("owned"); + + writeFileSync(join(project, extension), "// application extension\n"); + rmSync(join(project, settingsComponent)); + const repeat = planUpdate(project, REPO_TEMPLATES); + expect(repeat.changes.find((change) => change.path === extension)?.action).toBe("skip"); + expect(repeat.changes.find((change) => change.path === settingsComponent)?.action).toBe( + "skip", + ); + }); + function legacyAdminProject(): string { const templates = legacyAdminTemplatesCopy(); const project = join(tmp(), "legacy-admin-app"); diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 1ff01347..c3e7cd5a 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -242,6 +242,14 @@ export function planUpdate(projectRoot: string, templatesDir: string): UpdatePla const tier: Tier = controlledByTarget ? classified : locked?.tier ?? classified; if (tier === "owned") { + // A file introduced by a newer template or module has no previous lock + // entry. Seed it once when the application has not already claimed that + // path; subsequent edits or deletions stay protected by the recorded + // owned entry. + if (!locked && disk === null && newText !== null) { + changes.push({ path, tier, action: "add", note: "new owned seed in this version" }); + continue; + } const explicitlyOwned = ownedGlobs.some( (glob) => !glob.includes("*") && matchGlob(path, glob), ); @@ -440,6 +448,20 @@ function updatedFilesLock( } } + // Keep a tombstone for an owned seed that the application deleted or moved. + // Without the previous entry, the next update would mistake the missing path + // for a newly introduced seed and recreate it. + for (const [path, entry] of Object.entries(previous.files)) { + if (entry.tier !== "owned" || next.files[path]) continue; + const newText = treeText(newTree, path); + if ( + newText !== null && + classifyTier(path, newText, ownedGlobs, managedOverrides) === "owned" + ) { + next.files[path] = entry; + } + } + return next; } diff --git a/scripts/dev-container-refresh.mjs b/scripts/dev-container-refresh.mjs index f9cde7ed..628f2475 100644 --- a/scripts/dev-container-refresh.mjs +++ b/scripts/dev-container-refresh.mjs @@ -3,12 +3,14 @@ // end to end — the repetitive, error-prone dance done in one command: // 1. read the app's installed modules from .podokit/manifest.json // 2. back up .env.docker and .podokit/dev.json (instance config) -// 3. docker compose down +// 3. podo dev down (all profiles, route, and gateway lifecycle) // 4. regenerate with dev-app.mjs --published (container-friendly, avoids the // host file:-link dangling-symlink issue — docs/pitfalls.md P-008) // 5. restore .env.docker -// 6. docker compose up -d --build, then force-recreate api (env-cache fix so -// trustedOrigins pick up the restored CORS_ORIGIN — P-005 "Invalid origin") +// 6. podo dev up -d --build, then force-recreate api (env-cache fix so +// trustedOrigins pick up the restored CORS_ORIGIN — P-005 "Invalid origin"). +// Going through podo reconnects the regenerated web container to the shared +// gateway network and re-registers its hostname route. // 7. run Better Auth and TypeORM migrations (new auth/module tables) // 8. health-check: wait until the site answers 200 via Traefik // @@ -22,6 +24,7 @@ import { fileURLToPath } from "node:url"; import { externalPackageSpec, planRefreshModules, refreshHost } from "./dev-container-refresh-lib.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cliEntry = join(repoRoot, "packages/cli/dist/index.js"); const args = process.argv.slice(2); const appDir = args.find((a) => !a.startsWith("--")) && resolve(args.find((a) => !a.startsWith("--"))); const addFlag = (() => { @@ -69,8 +72,8 @@ const corsLine = hasEnv ? (readFileSync(envDocker, "utf8").match(/^CORS_ORIGIN=( const host = refreshHost(corsLine); console.log(`Instance host: ${host}`); -// 3) compose down -try { sh("docker", ["compose", "-f", compose, "down"], { cwd: appDir }); } catch { /* ok */ } +// 3) stop every module profile and unregister the old route +try { sh("node", [cliEntry, "dev", "down"], { cwd: appDir }); } catch { /* ok */ } // 4) regenerate (container-friendly). External modules must be installed before // podo can resolve and add them, so replay them after the bundled module pass. @@ -103,10 +106,11 @@ try { } } -// 6) up + rebuild, then force-recreate api so it re-reads the restored env -sh("docker", ["compose", "-f", compose, "up", "-d", "--build"], { cwd: appDir }); -sh("docker", ["compose", "-f", compose, "up", "-d", "--force-recreate", "--no-build", "api"], { cwd: appDir }); -sh("docker", ["compose", "-f", compose, "up", "-d"], { cwd: appDir }); // ensure traefik/web are up +// 6) up + rebuild through podo so the shared gateway route/network are restored, +// then force-recreate api so it re-reads the restored env. +sh("node", [cliEntry, "dev", "up", "-d", "--build"], { cwd: appDir }); +sh("node", [cliEntry, "dev", "up", "-d", "--force-recreate", "--no-build", "api"], { cwd: appDir }); +sh("node", [cliEntry, "dev", "up", "-d"], { cwd: appDir }); // 7) wait for api, then run migrations const waitApi = async () => { diff --git a/templates/fullstack-nest-svelte/README.md b/templates/fullstack-nest-svelte/README.md index f7644c6b..3ec91334 100644 --- a/templates/fullstack-nest-svelte/README.md +++ b/templates/fullstack-nest-svelte/README.md @@ -27,6 +27,7 @@ Run lifecycle and container commands from a second terminal: ```bash npx @podosoft/podokit dev url +npx @podosoft/podokit dev up -d # detached stack without source watching npx @podosoft/podokit dev ps npx @podosoft/podokit dev logs npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api @@ -35,6 +36,8 @@ npx @podosoft/podokit dev down `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`, `storage`, and `queue` when installed modules require Redis, MinIO, or a worker. +`dev up` uses the same shared gateway and module profiles without keeping Compose +Watch attached. You can still activate an additional Compose profile explicitly: ```bash diff --git a/templates/todo/README.md b/templates/todo/README.md index e4631bcd..7ff748cf 100644 --- a/templates/todo/README.md +++ b/templates/todo/README.md @@ -28,6 +28,7 @@ In a second terminal, apply the included Todo migration and use the lifecycle he ```bash npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api npx @podosoft/podokit dev url +npx @podosoft/podokit dev up -d # detached stack without source watching npx @podosoft/podokit dev ps npx @podosoft/podokit dev logs npx @podosoft/podokit dev down @@ -35,6 +36,8 @@ npx @podosoft/podokit dev down `dev watch` reads `.podokit/manifest.json` and automatically activates `cache`, `storage`, and `queue` when installed modules require Redis, MinIO, or a worker. +`dev up` uses the same shared gateway and module profiles without keeping Compose +Watch attached. You can still activate an additional Compose profile explicitly: ```bash