@podosoft/podokit 0.11.1 → 0.12.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 (94) hide show
  1. package/README.md +7 -3
  2. package/dist/add.d.ts +16 -0
  3. package/dist/add.js +63 -20
  4. package/dist/assemble.js +9 -9
  5. package/dist/dev.d.ts +26 -0
  6. package/dist/dev.js +297 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +82 -2
  9. package/dist/lib.d.ts +2 -0
  10. package/dist/lib.js +7 -1
  11. package/dist/locale.d.ts +22 -0
  12. package/dist/locale.js +184 -0
  13. package/dist/lockfile.d.ts +5 -3
  14. package/dist/lockfile.js +21 -5
  15. package/dist/remove.js +26 -23
  16. package/dist/templates/fullstack-nest-svelte/README.md +42 -0
  17. package/dist/templates/fullstack-nest-svelte/apps/api/Dockerfile +1 -0
  18. package/dist/templates/fullstack-nest-svelte/apps/api/scripts/.keep +1 -0
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/README.md +7 -5
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  21. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  22. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/en.json +6 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/locales/ko.json +6 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/web/vite.config.ts +3 -9
  25. package/dist/templates/fullstack-nest-svelte/compose.dev.yaml +8 -11
  26. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  27. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  28. package/dist/templates/fullstack-nest-svelte/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  29. package/dist/templates/fullstack-nest-svelte/dot-env.docker +2 -2
  30. package/dist/templates/fullstack-nest-svelte/dot-gitignore +3 -0
  31. package/dist/templates/fullstack-nest-svelte/dot-podokit/dev.json +4 -0
  32. package/dist/templates/fullstack-nest-svelte/tests/README.md +10 -4
  33. package/dist/templates/modules/admin-dashboard/files/apps/api/scripts/bootstrap-admin.mjs +187 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +9 -0
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +5 -1
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-errors.ts +3 -0
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/auth-redirect.ts +27 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +8 -3
  39. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  40. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +564 -0
  41. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +564 -0
  42. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +13 -12
  43. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +90 -722
  44. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +8 -1
  45. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/audit/+page.svelte +4 -4
  46. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/organizations/+page.svelte +2 -2
  47. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/sessions/+page.svelte +4 -4
  48. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +8 -2
  49. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +15 -4
  50. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/users/+page.svelte +4 -4
  51. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/login/+page.svelte +32 -35
  52. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(auth)/signup/+page.svelte +10 -3
  53. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +11 -7
  54. package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +78 -0
  55. package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +28 -1
  56. package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -1
  57. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +15 -0
  58. package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +59 -6
  59. package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +1 -1
  60. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  61. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -0
  62. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +2 -2
  63. package/dist/templates/modules/admin-dashboard/module.manifest.json +14 -3
  64. package/dist/templates/modules/auth/files/apps/api/scripts/configure-auth.mjs +22 -11
  65. package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +6 -2
  66. package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +50 -25
  67. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/SKILL.md +15 -2
  68. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/agents/openai.yaml +2 -2
  69. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/bootstrap-admin.md +51 -0
  70. package/dist/templates/modules/auth/files/dot-claude/skills/podokit-configure-auth/references/google.md +44 -0
  71. package/dist/templates/modules/auth/module.manifest.json +3 -0
  72. package/dist/templates/todo/README.md +42 -0
  73. package/dist/templates/todo/apps/api/Dockerfile +1 -0
  74. package/dist/templates/todo/apps/api/scripts/.keep +1 -0
  75. package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -5
  76. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/en.json +1 -0
  77. package/dist/templates/todo/apps/web/src/lib/i18n/catalogs/app/ko.json +1 -0
  78. package/dist/templates/todo/apps/web/src/lib/i18n/locales/en.json +6 -0
  79. package/dist/templates/todo/apps/web/src/lib/i18n/locales/ko.json +6 -0
  80. package/dist/templates/todo/apps/web/vite.config.ts +3 -9
  81. package/dist/templates/todo/compose.dev.yaml +8 -11
  82. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/SKILL.md +63 -0
  83. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/agents/openai.yaml +4 -0
  84. package/dist/templates/todo/dot-claude/skills/podokit-add-locale/references/catalog-format.md +49 -0
  85. package/dist/templates/todo/dot-env.docker +2 -2
  86. package/dist/templates/todo/dot-gitignore +3 -0
  87. package/dist/templates/todo/dot-podokit/dev.json +4 -0
  88. package/dist/templates/todo/tests/README.md +10 -4
  89. package/dist/update.js +48 -7
  90. package/package.json +1 -1
  91. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/en.ts +0 -10
  92. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/i18n/ko.ts +0 -11
  93. package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +0 -10
  94. package/dist/templates/todo/apps/web/src/lib/i18n/ko.ts +0 -11
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ const remove_1 = require("./remove");
12
12
  const inspect_1 = require("./inspect");
13
13
  const update_1 = require("./update");
14
14
  const eject_1 = require("./eject");
15
+ const dev_1 = require("./dev");
16
+ const locale_1 = require("./locale");
15
17
  const HELP = `podo — PodoKit project generator
16
18
 
17
19
  Usage:
@@ -21,6 +23,8 @@ Usage:
21
23
  podo status Show version, modules, file tiers, and local edits
22
24
  podo diff List PodoKit-managed files you have edited
23
25
  podo doctor Check framework versions against supported ranges
26
+ podo dev <action> [...] Run the shared, portless container development gateway
27
+ podo locale <command> Add, validate, activate, or list JSON locales
24
28
  podo update [--apply] Preview (or apply) what a version update would change
25
29
  podo eject <path...> Take ownership of managed files (update skips them)
26
30
 
@@ -28,6 +32,8 @@ Options:
28
32
  --template <t> Template to scaffold (see below)
29
33
  --dir <path> Target directory (default: ./<name>)
30
34
  --pm <name> Package manager: npm | pnpm | yarn (default: npm)
35
+ --name <label> Display name for a locale
36
+ --direction <direction> Text direction: ltr | rtl (default: ltr)
31
37
  --adopt Adopt colliding paths explicitly declared managed by a module
32
38
  --no-ai Skip AI agent guidance (AGENTS.md, CLAUDE.md, editor rules)
33
39
  -y, --yes Skip prompts and accept defaults
@@ -42,7 +48,14 @@ Example:
42
48
  cd my-app && npx @podosoft/podokit add auth
43
49
  `;
44
50
  function parseArgs(argv) {
45
- const parsed = { help: false, yes: false, apply: false, adopt: false, ai: true };
51
+ const parsed = {
52
+ help: false,
53
+ yes: false,
54
+ apply: false,
55
+ adopt: false,
56
+ ai: true,
57
+ positionals: [],
58
+ };
46
59
  const positionals = [];
47
60
  for (let i = 0; i < argv.length; i += 1) {
48
61
  const arg = argv[i];
@@ -73,12 +86,23 @@ function parseArgs(argv) {
73
86
  else if (arg === "--pm") {
74
87
  parsed.pm = argv[++i];
75
88
  }
89
+ else if (arg === "--name") {
90
+ parsed.localeName = argv[++i];
91
+ }
92
+ else if (arg === "--direction") {
93
+ const direction = argv[++i];
94
+ if (direction !== "ltr" && direction !== "rtl") {
95
+ throw new Error('--direction must be either "ltr" or "rtl".');
96
+ }
97
+ parsed.localeDirection = direction;
98
+ }
76
99
  else if (arg !== undefined && !arg.startsWith("-")) {
77
100
  positionals.push(arg);
78
101
  }
79
102
  }
80
103
  parsed.command = positionals[0];
81
104
  parsed.name = positionals[1];
105
+ parsed.positionals = positionals;
82
106
  return parsed;
83
107
  }
84
108
  function fail(message) {
@@ -203,6 +227,62 @@ async function main(argv) {
203
227
  }
204
228
  return;
205
229
  }
230
+ if (args.command === "dev") {
231
+ try {
232
+ (0, dev_1.runDevCommand)(process.cwd(), args.name, argv.slice(2));
233
+ }
234
+ catch (err) {
235
+ fail(err.message);
236
+ }
237
+ return;
238
+ }
239
+ if (args.command === "locale") {
240
+ const action = args.positionals[1] ?? "list";
241
+ const code = args.positionals[2];
242
+ try {
243
+ if (action === "add") {
244
+ if (!code)
245
+ fail("Usage: podo locale add <code> [--name <label>] [--direction ltr|rtl]");
246
+ const definition = (0, locale_1.addLocale)(process.cwd(), code, {
247
+ name: args.localeName,
248
+ direction: args.localeDirection,
249
+ });
250
+ process.stdout.write(`Added ${definition.code} (${definition.name}) as inactive. Translate catalogs, run ` +
251
+ `"podo locale validate ${definition.code}", then activate it.\n`);
252
+ return;
253
+ }
254
+ if (action === "validate") {
255
+ const locales = code ? [code] : (0, locale_1.listLocales)(process.cwd()).map((locale) => locale.code);
256
+ for (const locale of locales) {
257
+ const coverage = (0, locale_1.validateLocale)(process.cwd(), locale);
258
+ process.stdout.write(`${coverage.definition.code.padEnd(10)} ${String(coverage.percent).padStart(3)}% ` +
259
+ `(${coverage.translated}/${coverage.total})` +
260
+ `${coverage.missing.length ? ` missing ${coverage.missing.length}` : ""}\n`);
261
+ }
262
+ return;
263
+ }
264
+ if (action === "activate" || action === "deactivate") {
265
+ if (!code)
266
+ fail(`Usage: podo locale ${action} <code>`);
267
+ const coverage = (0, locale_1.setLocaleEnabled)(process.cwd(), code, action === "activate");
268
+ process.stdout.write(`${action === "activate" ? "Activated" : "Deactivated"} ${coverage.definition.code} ` +
269
+ `(${coverage.percent}% translated; missing keys use the configured fallback).\n`);
270
+ return;
271
+ }
272
+ if (action === "list") {
273
+ for (const locale of (0, locale_1.listLocales)(process.cwd())) {
274
+ const coverage = (0, locale_1.validateLocale)(process.cwd(), locale.code);
275
+ process.stdout.write(`${locale.enabled ? "active " : "inactive"} ${locale.code.padEnd(10)} ` +
276
+ `${locale.name} ${coverage.percent}%\n`);
277
+ }
278
+ return;
279
+ }
280
+ fail(`Unknown locale command "${action}". Use add, validate, activate, deactivate, or list.`);
281
+ }
282
+ catch (err) {
283
+ fail(err.message);
284
+ }
285
+ }
206
286
  if (args.command === "update") {
207
287
  const templatesDir = (0, node_path_1.join)(__dirname, "templates");
208
288
  try {
@@ -238,7 +318,7 @@ async function main(argv) {
238
318
  return;
239
319
  }
240
320
  if (args.command === "eject") {
241
- const targets = argv.filter((a) => !a.startsWith("-")).slice(1);
321
+ const targets = args.positionals.slice(1);
242
322
  if (!targets.length) {
243
323
  fail("Usage: podo eject <path...>");
244
324
  }
package/dist/lib.d.ts CHANGED
@@ -8,6 +8,8 @@ export { status, diff, doctor, SUPPORTED_FRAMEWORKS } from "./inspect";
8
8
  export type { StatusReport, DoctorFinding } from "./inspect";
9
9
  export { planUpdate, summarize } from "./update";
10
10
  export type { UpdatePlan, FileChange } from "./update";
11
+ export { readDevConfig, renderRoute, renderRuntimeCompose, resolveDevRuntime, runDevCommand } from "./dev";
12
+ export type { CommandRunner, DevConfig, DevRuntime } from "./dev";
11
13
  /** Absolute path to the templates bundled in this installed package. */
12
14
  export declare function builtinTemplatesDir(): string;
13
15
  /** Absolute path to the modules bundled in this installed package. */
package/dist/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.summarize = exports.planUpdate = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.create = void 0;
3
+ exports.runDevCommand = exports.resolveDevRuntime = exports.renderRuntimeCompose = exports.renderRoute = exports.readDevConfig = exports.summarize = exports.planUpdate = exports.SUPPORTED_FRAMEWORKS = exports.doctor = exports.diff = exports.status = exports.addModule = exports.listModules = exports.isKnownTemplate = exports.TEMPLATE_NAMES = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = exports.create = void 0;
4
4
  exports.builtinTemplatesDir = builtinTemplatesDir;
5
5
  exports.builtinModulesDir = builtinModulesDir;
6
6
  // Library entry point: the reusable functions behind the `podo` CLI, for other
@@ -24,6 +24,12 @@ Object.defineProperty(exports, "SUPPORTED_FRAMEWORKS", { enumerable: true, get:
24
24
  var update_1 = require("./update");
25
25
  Object.defineProperty(exports, "planUpdate", { enumerable: true, get: function () { return update_1.planUpdate; } });
26
26
  Object.defineProperty(exports, "summarize", { enumerable: true, get: function () { return update_1.summarize; } });
27
+ var dev_1 = require("./dev");
28
+ Object.defineProperty(exports, "readDevConfig", { enumerable: true, get: function () { return dev_1.readDevConfig; } });
29
+ Object.defineProperty(exports, "renderRoute", { enumerable: true, get: function () { return dev_1.renderRoute; } });
30
+ Object.defineProperty(exports, "renderRuntimeCompose", { enumerable: true, get: function () { return dev_1.renderRuntimeCompose; } });
31
+ Object.defineProperty(exports, "resolveDevRuntime", { enumerable: true, get: function () { return dev_1.resolveDevRuntime; } });
32
+ Object.defineProperty(exports, "runDevCommand", { enumerable: true, get: function () { return dev_1.runDevCommand; } });
27
33
  /** Absolute path to the templates bundled in this installed package. */
28
34
  function builtinTemplatesDir() {
29
35
  return (0, node_path_1.join)(__dirname, "templates");
@@ -0,0 +1,22 @@
1
+ export type LocaleDirection = "ltr" | "rtl";
2
+ export interface LocaleDefinition {
3
+ code: string;
4
+ name: string;
5
+ direction: LocaleDirection;
6
+ enabled: boolean;
7
+ }
8
+ export interface LocaleCoverage {
9
+ definition: LocaleDefinition;
10
+ translated: number;
11
+ total: number;
12
+ percent: number;
13
+ missing: string[];
14
+ }
15
+ export declare function normalizeLocaleCode(value: string): string;
16
+ export declare function listLocales(projectRoot: string): LocaleDefinition[];
17
+ export declare function validateLocale(projectRoot: string, rawCode: string): LocaleCoverage;
18
+ export declare function addLocale(projectRoot: string, rawCode: string, options?: {
19
+ name?: string;
20
+ direction?: LocaleDirection;
21
+ }): LocaleDefinition;
22
+ export declare function setLocaleEnabled(projectRoot: string, rawCode: string, enabled: boolean): LocaleCoverage;
package/dist/locale.js ADDED
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeLocaleCode = normalizeLocaleCode;
4
+ exports.listLocales = listLocales;
5
+ exports.validateLocale = validateLocale;
6
+ exports.addLocale = addLocale;
7
+ exports.setLocaleEnabled = setLocaleEnabled;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const WEB_I18N = (0, node_path_1.join)("apps", "web", "src", "lib", "i18n");
11
+ function definitionsDir(projectRoot) {
12
+ return (0, node_path_1.join)(projectRoot, WEB_I18N, "locales");
13
+ }
14
+ function catalogsDir(projectRoot) {
15
+ return (0, node_path_1.join)(projectRoot, WEB_I18N, "catalogs");
16
+ }
17
+ function appCatalogDir(projectRoot) {
18
+ return (0, node_path_1.join)(catalogsDir(projectRoot), "app");
19
+ }
20
+ function normalizeLocaleCode(value) {
21
+ try {
22
+ const normalized = Intl.getCanonicalLocales(value)[0];
23
+ if (!normalized)
24
+ throw new Error("missing locale");
25
+ return normalized;
26
+ }
27
+ catch {
28
+ throw new Error(`Invalid locale code "${value}". Use a BCP 47 language tag such as en, ko, or pt-BR.`);
29
+ }
30
+ }
31
+ function readJson(path) {
32
+ const value = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
34
+ throw new Error(`Expected a JSON object: ${path}`);
35
+ }
36
+ return value;
37
+ }
38
+ function readDefinition(path) {
39
+ const value = readJson(path);
40
+ const code = typeof value.code === "string" ? normalizeLocaleCode(value.code) : "";
41
+ const name = typeof value.name === "string" ? value.name.trim() : "";
42
+ const direction = value.direction;
43
+ const enabled = value.enabled;
44
+ if (!code || !name || (direction !== "ltr" && direction !== "rtl") || typeof enabled !== "boolean") {
45
+ throw new Error(`Invalid locale definition: ${path}`);
46
+ }
47
+ if (`${code}.json` !== (0, node_path_1.basename)(path)) {
48
+ throw new Error(`Locale definition filename must match its canonical code: ${code}.json`);
49
+ }
50
+ return { code, name, direction, enabled };
51
+ }
52
+ function listLocales(projectRoot) {
53
+ const dir = definitionsDir(projectRoot);
54
+ if (!(0, node_fs_1.existsSync)(dir))
55
+ throw new Error("This project does not contain PodoKit JSON locale definitions.");
56
+ return (0, node_fs_1.readdirSync)(dir)
57
+ .filter((name) => name.endsWith(".json"))
58
+ .map((name) => readDefinition((0, node_path_1.join)(dir, name)))
59
+ .sort((left, right) => left.code.localeCompare(right.code));
60
+ }
61
+ function merge(target, source) {
62
+ const result = { ...target };
63
+ for (const [key, value] of Object.entries(source)) {
64
+ const previous = result[key];
65
+ if (previous && typeof previous === "object" && !Array.isArray(previous) &&
66
+ value && typeof value === "object" && !Array.isArray(value)) {
67
+ result[key] = merge(previous, value);
68
+ }
69
+ else {
70
+ result[key] = value;
71
+ }
72
+ }
73
+ return result;
74
+ }
75
+ function composedCatalog(projectRoot, code) {
76
+ const root = catalogsDir(projectRoot);
77
+ if (!(0, node_fs_1.existsSync)(root))
78
+ return {};
79
+ const sources = (0, node_fs_1.readdirSync)(root, { withFileTypes: true })
80
+ .filter((entry) => entry.isDirectory())
81
+ .map((entry) => entry.name)
82
+ .sort((left, right) => {
83
+ const leftApp = left === "app" ? 1 : 0;
84
+ const rightApp = right === "app" ? 1 : 0;
85
+ return leftApp - rightApp || left.localeCompare(right);
86
+ });
87
+ let catalog = {};
88
+ for (const source of sources) {
89
+ const path = (0, node_path_1.join)(root, source, `${code}.json`);
90
+ if ((0, node_fs_1.existsSync)(path))
91
+ catalog = merge(catalog, readJson(path));
92
+ }
93
+ return catalog;
94
+ }
95
+ function flatten(value, prefix = "", out = new Map()) {
96
+ if (Array.isArray(value)) {
97
+ if (value.length === 0)
98
+ out.set(prefix, value);
99
+ for (const [index, child] of value.entries()) {
100
+ flatten(child, `${prefix}[${index}]`, out);
101
+ }
102
+ }
103
+ else if (value && typeof value === "object") {
104
+ for (const [key, child] of Object.entries(value)) {
105
+ flatten(child, prefix ? `${prefix}.${key}` : key, out);
106
+ }
107
+ }
108
+ else {
109
+ out.set(prefix, value);
110
+ }
111
+ return out;
112
+ }
113
+ function placeholders(value) {
114
+ if (typeof value !== "string")
115
+ return [];
116
+ return [...value.matchAll(/\{(\w+)\}/g)].map((match) => match[1] ?? "").sort();
117
+ }
118
+ function validateLocale(projectRoot, rawCode) {
119
+ const code = normalizeLocaleCode(rawCode);
120
+ const definitionPath = (0, node_path_1.join)(definitionsDir(projectRoot), `${code}.json`);
121
+ if (!(0, node_fs_1.existsSync)(definitionPath))
122
+ throw new Error(`Locale ${code} has not been added.`);
123
+ const definition = readDefinition(definitionPath);
124
+ const base = new Map([...flatten(composedCatalog(projectRoot, "en"))].filter(([, value]) => value !== ""));
125
+ const selected = flatten(composedCatalog(projectRoot, code));
126
+ const missing = [];
127
+ for (const [key, baseValue] of base) {
128
+ if (!selected.has(key) || selected.get(key) === "") {
129
+ missing.push(key);
130
+ continue;
131
+ }
132
+ const selectedValue = selected.get(key);
133
+ const baseKind = Array.isArray(baseValue) ? "array" : typeof baseValue;
134
+ const selectedKind = Array.isArray(selectedValue) ? "array" : typeof selectedValue;
135
+ if (baseKind !== selectedKind)
136
+ throw new Error(`${code}:${key} must be ${baseKind}, received ${selectedKind}`);
137
+ if (placeholders(baseValue).join(",") !== placeholders(selectedValue).join(",")) {
138
+ throw new Error(`${code}:${key} must preserve placeholders ${placeholders(baseValue).join(", ")}`);
139
+ }
140
+ }
141
+ const total = base.size;
142
+ const translated = total - missing.length;
143
+ return {
144
+ definition,
145
+ translated,
146
+ total,
147
+ percent: total === 0 ? 100 : Math.round((translated / total) * 100),
148
+ missing,
149
+ };
150
+ }
151
+ function defaultLocaleName(code) {
152
+ try {
153
+ return new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code;
154
+ }
155
+ catch {
156
+ return code;
157
+ }
158
+ }
159
+ function addLocale(projectRoot, rawCode, options = {}) {
160
+ const code = normalizeLocaleCode(rawCode);
161
+ const definitionPath = (0, node_path_1.join)(definitionsDir(projectRoot), `${code}.json`);
162
+ const catalogPath = (0, node_path_1.join)(appCatalogDir(projectRoot), `${code}.json`);
163
+ if ((0, node_fs_1.existsSync)(definitionPath) || (0, node_fs_1.existsSync)(catalogPath))
164
+ throw new Error(`Locale ${code} already exists.`);
165
+ const definition = {
166
+ code,
167
+ name: options.name?.trim() || defaultLocaleName(code),
168
+ direction: options.direction ?? "ltr",
169
+ enabled: false,
170
+ };
171
+ (0, node_fs_1.mkdirSync)(definitionsDir(projectRoot), { recursive: true });
172
+ (0, node_fs_1.mkdirSync)(appCatalogDir(projectRoot), { recursive: true });
173
+ (0, node_fs_1.writeFileSync)(definitionPath, `${JSON.stringify(definition, null, 2)}\n`);
174
+ (0, node_fs_1.writeFileSync)(catalogPath, "{}\n");
175
+ return definition;
176
+ }
177
+ function setLocaleEnabled(projectRoot, rawCode, enabled) {
178
+ const coverage = validateLocale(projectRoot, rawCode);
179
+ if (!enabled && coverage.definition.code === "en")
180
+ throw new Error("The PodoKit English fallback cannot be deactivated.");
181
+ const definition = { ...coverage.definition, enabled };
182
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(definitionsDir(projectRoot), `${definition.code}.json`), `${JSON.stringify(definition, null, 2)}\n`);
183
+ return { ...coverage, definition };
184
+ }
@@ -32,6 +32,8 @@ export interface PodokitManifest {
32
32
  modules: ManifestModule[];
33
33
  /** Globs whose files are user-owned and never touched by update. */
34
34
  ownedGlobs: string[];
35
+ /** Module-supplied globs that stay managed even inside a broad owned glob. */
36
+ managedOverrides?: string[];
35
37
  }
36
38
  export interface FileEntry {
37
39
  tier: Tier;
@@ -64,11 +66,11 @@ export declare function podokitVersion(): string;
64
66
  /** Match a POSIX relative path against a simple glob supporting `*` and `**`. */
65
67
  export declare function matchGlob(path: string, glob: string): boolean;
66
68
  /** Classify a file's ownership tier. See ADR-0010 priority order. */
67
- export declare function classifyTier(relPath: string, content: string | Buffer, ownedGlobs: string[]): Tier;
69
+ export declare function classifyTier(relPath: string, content: string | Buffer, ownedGlobs: string[], managedOverrides?: string[]): Tier;
68
70
  /** List project files as POSIX-relative paths, skipping generated/vendored dirs. */
69
71
  export declare function walkFiles(projectRoot: string, prefix?: string): string[];
70
72
  /** Recompute the files.lock by hashing and classifying every project file. */
71
- export declare function computeFilesLock(projectRoot: string, ownedGlobs: string[]): FilesLock;
73
+ export declare function computeFilesLock(projectRoot: string, ownedGlobs: string[], managedOverrides?: string[]): FilesLock;
72
74
  export declare function readFilesLock(projectRoot: string): FilesLock | null;
73
75
  export interface Drift {
74
76
  /** Managed/assembled files whose content differs from what PodoKit wrote. */
@@ -101,4 +103,4 @@ export declare function mergeOwnedGlobs(existing: string[], add: string[]): stri
101
103
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
102
104
  * survive every future recompute. No-op on projects without a manifest.
103
105
  */
104
- export declare function recordModules(projectRoot: string, moduleInputs: (string | ManifestModuleInput)[], version?: string, ownedGlobs?: string[], lockSnapshot?: RecordModulesLockSnapshot): void;
106
+ export declare function recordModules(projectRoot: string, moduleInputs: (string | ManifestModuleInput)[], version?: string, ownedGlobs?: string[], lockSnapshot?: RecordModulesLockSnapshot, managedOverrides?: string[]): void;
package/dist/lockfile.js CHANGED
@@ -45,6 +45,9 @@ const WALK_IGNORE = new Set([
45
45
  exports.DEFAULT_OWNED_GLOBS = [
46
46
  "apps/web/src/routes/**",
47
47
  "apps/web/src/lib/components/ui/**",
48
+ // Locale definitions and app messages are application-owned extension points.
49
+ "apps/web/src/lib/i18n/locales/**",
50
+ "apps/web/src/lib/i18n/catalogs/app/**",
48
51
  // Owned Nest DI extension slot — override providers here; update never touches it.
49
52
  "apps/api/src/app.extensions.ts",
50
53
  // AI agent guidance — yours to customize; update never touches it.
@@ -62,6 +65,7 @@ exports.DEFAULT_OWNED_GLOBS = [
62
65
  ".devcontainer/**",
63
66
  ".env.docker",
64
67
  "infra/traefik/**",
68
+ ".podokit/dev.json",
65
69
  ];
66
70
  const TEXT_EXT = /\.(json|md|ts|tsx|js|mjs|cjs|css|html|yml|yaml|txt|env|svelte|gitignore|example)$/i;
67
71
  function podokitDir(projectRoot) {
@@ -114,7 +118,12 @@ function isTextPath(relPath) {
114
118
  return relPath.startsWith("dot-") || TEXT_EXT.test(relPath);
115
119
  }
116
120
  /** Classify a file's ownership tier. See ADR-0010 priority order. */
117
- function classifyTier(relPath, content, ownedGlobs) {
121
+ function classifyTier(relPath, content, ownedGlobs, managedOverrides = []) {
122
+ const explicitlyOwned = ownedGlobs.some((glob) => !glob.includes("*") && matchGlob(relPath, glob));
123
+ if (explicitlyOwned)
124
+ return "owned";
125
+ if (managedOverrides.some((glob) => matchGlob(relPath, glob)))
126
+ return "managed";
118
127
  if (ownedGlobs.some((glob) => matchGlob(relPath, glob)))
119
128
  return "owned";
120
129
  if (isTextPath(relPath) && content.toString("utf8").includes(INJECTION_MARKER))
@@ -138,11 +147,14 @@ function walkFiles(projectRoot, prefix = "") {
138
147
  return out;
139
148
  }
140
149
  /** Recompute the files.lock by hashing and classifying every project file. */
141
- function computeFilesLock(projectRoot, ownedGlobs) {
150
+ function computeFilesLock(projectRoot, ownedGlobs, managedOverrides = []) {
142
151
  const files = {};
143
152
  for (const rel of walkFiles(projectRoot).sort()) {
144
153
  const content = (0, node_fs_1.readFileSync)((0, node_path_1.join)(projectRoot, rel));
145
- files[rel] = { tier: classifyTier(rel, content, ownedGlobs), outHash: (0, podokit_template_engine_1.hashContent)(content) };
154
+ files[rel] = {
155
+ tier: classifyTier(rel, content, ownedGlobs, managedOverrides),
156
+ outHash: (0, podokit_template_engine_1.hashContent)(content),
157
+ };
146
158
  }
147
159
  return { schemaVersion: exports.LOCK_SCHEMA_VERSION, files };
148
160
  }
@@ -185,6 +197,7 @@ function initLockfile(projectRoot, options) {
185
197
  answers: options.answers,
186
198
  modules: [],
187
199
  ownedGlobs,
200
+ managedOverrides: [],
188
201
  };
189
202
  writeManifest(projectRoot, manifest);
190
203
  writeFilesLock(projectRoot, computeFilesLock(projectRoot, ownedGlobs));
@@ -209,7 +222,7 @@ function mergeOwnedGlobs(existing, add) {
209
222
  * `ownedGlobs` a module declares are merged into the manifest so its owned paths
210
223
  * survive every future recompute. No-op on projects without a manifest.
211
224
  */
212
- function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot) {
225
+ function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnapshot, managedOverrides) {
213
226
  const manifest = readManifest(projectRoot);
214
227
  if (!manifest)
215
228
  return;
@@ -234,8 +247,11 @@ function recordModules(projectRoot, moduleInputs, version, ownedGlobs, lockSnaps
234
247
  }
235
248
  if (ownedGlobs?.length)
236
249
  manifest.ownedGlobs = mergeOwnedGlobs(manifest.ownedGlobs, ownedGlobs);
250
+ if (managedOverrides?.length) {
251
+ manifest.managedOverrides = mergeOwnedGlobs(manifest.managedOverrides ?? [], managedOverrides);
252
+ }
237
253
  writeManifest(projectRoot, manifest);
238
- const computed = computeFilesLock(projectRoot, manifest.ownedGlobs);
254
+ const computed = computeFilesLock(projectRoot, manifest.ownedGlobs, manifest.managedOverrides ?? []);
239
255
  if (!lockSnapshot) {
240
256
  writeFilesLock(projectRoot, computed);
241
257
  return;
package/dist/remove.js CHANGED
@@ -107,8 +107,11 @@ function removeModule(options) {
107
107
  manifest.modules = manifest.modules
108
108
  .filter((m) => m.name !== module)
109
109
  .map((m, i) => ({ ...m, order: i }));
110
+ const sharedManagedOverrides = new Set(others.flatMap((other) => other.manifest.managedOverrides ?? []));
111
+ const targetManagedOverrides = new Set(targetManifest.managedOverrides ?? []);
112
+ manifest.managedOverrides = (manifest.managedOverrides ?? []).filter((glob) => !targetManagedOverrides.has(glob) || sharedManagedOverrides.has(glob));
110
113
  (0, lockfile_1.writeManifest)(projectRoot, manifest);
111
- (0, lockfile_1.writeFilesLock)(projectRoot, (0, lockfile_1.computeFilesLock)(projectRoot, manifest.ownedGlobs));
114
+ (0, lockfile_1.writeFilesLock)(projectRoot, (0, lockfile_1.computeFilesLock)(projectRoot, manifest.ownedGlobs, manifest.managedOverrides));
112
115
  return { module, removed, keptEdited, keptShared, unwired };
113
116
  }
114
117
  /** Remove now-empty directories left behind by a deleted file, up to the root. */
@@ -126,33 +129,33 @@ function pruneEmptyDirs(projectRoot, relDir) {
126
129
  }
127
130
  }
128
131
  function pruneAppPackage(projectRoot, target, others) {
129
- if (!(target.dependencies || target.devDependencies || target.scripts))
130
- return;
131
- const appPkgPath = (0, node_path_1.join)(projectRoot, "apps", target.targetApp, "package.json");
132
- if (!(0, node_fs_1.existsSync)(appPkgPath))
133
- return;
134
- const keep = (section) => new Set(others.flatMap((o) => Object.keys(o.manifest[section] ?? {})));
135
- const pkg = readJson(appPkgPath);
136
- let changed = false;
137
- for (const section of ["dependencies", "devDependencies", "scripts"]) {
138
- const declared = target[section];
139
- const current = pkg[section];
140
- if (!declared || !current)
132
+ for (const [app, declaration] of (0, add_1.modulePackageOverlays)(target)) {
133
+ const appPkgPath = (0, node_path_1.join)(projectRoot, "apps", app, "package.json");
134
+ if (!(0, node_fs_1.existsSync)(appPkgPath))
141
135
  continue;
142
- const shared = keep(section);
143
- for (const name of Object.keys(declared)) {
144
- if (shared.has(name))
136
+ const keep = (section) => new Set(others.flatMap((other) => Object.keys((0, add_1.modulePackageOverlays)(other.manifest).get(app)?.[section] ?? {})));
137
+ const pkg = readJson(appPkgPath);
138
+ let changed = false;
139
+ for (const section of ["dependencies", "devDependencies", "scripts"]) {
140
+ const declared = declaration[section];
141
+ const current = pkg[section];
142
+ if (!declared || !current)
145
143
  continue;
146
- if (name in current) {
147
- delete current[name];
148
- changed = true;
144
+ const shared = keep(section);
145
+ for (const name of Object.keys(declared)) {
146
+ if (shared.has(name))
147
+ continue;
148
+ if (name in current) {
149
+ delete current[name];
150
+ changed = true;
151
+ }
149
152
  }
153
+ if (current && Object.keys(current).length === 0)
154
+ delete pkg[section];
150
155
  }
151
- if (current && Object.keys(current).length === 0)
152
- delete pkg[section];
156
+ if (changed)
157
+ (0, node_fs_1.writeFileSync)(appPkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
153
158
  }
154
- if (changed)
155
- (0, node_fs_1.writeFileSync)(appPkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
156
159
  }
157
160
  function pruneEnvExample(projectRoot, target, others) {
158
161
  if (!target.env?.length)
@@ -8,10 +8,49 @@ Full-stack TypeScript starter generated with [PodoKit](https://github.com/podoso
8
8
 
9
9
  ## Getting started
10
10
 
11
+ ### Recommended: containerized development
12
+
11
13
  ```bash
12
14
  {{packageManager}} install
13
15
  cp .env.example .env
16
+ npx @podosoft/podokit dev watch
17
+ ```
18
+
19
+ Open the URL printed by the command; a new project defaults to
20
+ **http://{{projectName}}.localhost**. The committed `.podokit/dev.json` is the
21
+ source of truth if you change the hostname. The first running PodoKit project starts
22
+ one user-level Traefik gateway on `127.0.0.1:80`; additional projects reuse it and
23
+ route by that hostname. Even a single app uses
24
+ the same topology, with one route and no project-specific host port.
25
+
26
+ Run lifecycle and container commands from a second terminal:
27
+
28
+ ```bash
29
+ npx @podosoft/podokit dev url
30
+ npx @podosoft/podokit dev ps
31
+ npx @podosoft/podokit dev logs
32
+ npx @podosoft/podokit dev exec api npm run migration:run -w {{projectName}}-api
33
+ npx @podosoft/podokit dev down
34
+ ```
35
+
36
+ If installed modules require extra services, use their profiles on the initial
37
+ watch command instead:
14
38
 
39
+ ```bash
40
+ npx @podosoft/podokit dev watch \
41
+ --profile cache --profile storage --profile queue
42
+ ```
43
+
44
+ Pass the same profile flags to other lifecycle commands when applicable. `dev down`
45
+ removes this project's stack and route. If it is the final registered
46
+ project, it also removes the shared gateway and network. Source changes are synced
47
+ through Compose Watch, including Vite HMR on the same portless browser origin.
48
+
49
+ ### Alternative: host processes
50
+
51
+ Use this loop when you want only the web and API processes on the host:
52
+
53
+ ```bash
15
54
  # start local PostgreSQL + Redis
16
55
  docker compose -f infra/docker/docker-compose.yml up -d
17
56
 
@@ -35,6 +74,9 @@ generate and run a migration:
35
74
  Want a worked example? Generate the `todo` template instead:
36
75
  `npx @podosoft/podokit create my-app --template todo`.
37
76
 
77
+ For multi-project routing, container profiles, migrations, and OAuth over a
78
+ stable HTTPS tunnel, see the PodoKit [development guide](https://github.com/podosoft-dev/podokit/blob/main/docs/development.md).
79
+
38
80
  ## Deploy
39
81
 
40
82
  Docker Compose in `infra/docker`; example k3s manifests in `infra/k3s`
@@ -20,6 +20,7 @@ RUN addgroup -S app && adduser -S app -G app
20
20
  COPY --from=build /app/node_modules ./node_modules
21
21
  COPY --from=deps /app/apps/api ./apps/api
22
22
  COPY --from=build /app/apps/api/dist ./apps/api/dist
23
+ COPY --from=build /app/apps/api/scripts ./apps/api/scripts
23
24
  WORKDIR /app/apps/api
24
25
  USER app
25
26
  EXPOSE 3000
@@ -1,7 +1,9 @@
1
- # i18n
1
+ # Localization
2
2
 
3
- Locales use [typesafe-i18n](https://github.com/ivanhofer/typesafe-i18n).
3
+ Locale definitions live in `locales/<code>.json`. Product copy and intentional
4
+ module overrides live in the app-owned `catalogs/app/<code>.json` files.
4
5
 
5
- - `en.ts` is the base locale; `ko.ts` mirrors its shape.
6
- - Run `npx typesafe-i18n` to generate the typed runtime and Svelte store,
7
- then use `$LL.appTitle()` in components. Add keys to every locale.
6
+ The admin-dashboard module installs the composed JSON message runtime and its
7
+ complete English and Korean catalogs. Use `podo locale add`, `validate`, and
8
+ `activate` to extend it. See the generated `$podokit-add-locale` skill for the
9
+ complete workflow.
@@ -0,0 +1,6 @@
1
+ {
2
+ "code": "en",
3
+ "name": "English",
4
+ "direction": "ltr",
5
+ "enabled": true
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "code": "ko",
3
+ "name": "한국어",
4
+ "direction": "ltr",
5
+ "enabled": true
6
+ }