Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-wolves-connect.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-");
Expand Down
29 changes: 26 additions & 3 deletions packages/cli/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,21 @@ 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) {
throw new Error(
`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));
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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);
Expand All @@ -403,5 +426,5 @@ export function runDevCommand(
return;
}

throw new Error("Usage: podo dev <watch|exec|logs|ps|down|url> [docker compose options]");
throw new Error("Usage: podo dev <watch|up|exec|logs|ps|down|url> [docker compose options]");
}
47 changes: 47 additions & 0 deletions packages/cli/src/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down Expand Up @@ -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;
}

Expand Down
22 changes: 13 additions & 9 deletions scripts/dev-container-refresh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand All @@ -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 = (() => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions templates/fullstack-nest-svelte/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions templates/todo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@ 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
```

`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
Expand Down