@tatara/cli 0.1.3 → 0.3.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 (54) hide show
  1. package/dist/bin.mjs +590 -14
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/apps/admin/server.ts +3 -2
  4. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
  5. package/templates/features/auth/files/src/core/auth/middleware.ts +1 -1
  6. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
  7. package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +1 -1
  8. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +1 -1
  9. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +1 -1
  10. package/templates/features/authz/files/src/core/authz/middleware.ts +1 -1
  11. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +1 -1
  12. package/templates/features/authz/files/src/core/authz/services/grants.ts +1 -1
  13. package/templates/features/authz/files/src/core/authz/services/members.ts +1 -1
  14. package/templates/features/authz/files/src/core/authz/services/roles.ts +1 -1
  15. package/templates/features/base/files/AGENTS.md +1 -0
  16. package/templates/features/base/files/src/apps/api/server.ts +3 -2
  17. package/templates/features/base/files/src/apps/cli/index.ts +4 -4
  18. package/templates/features/base/files/src/core/http/app.ts +28 -41
  19. package/templates/features/base/files/src/core/http/routes.ts +7 -11
  20. package/templates/features/base/files/src/core/logging/logger.ts +4 -12
  21. package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +1 -1
  22. package/templates/features/db/files/src/core/db/commands/migrate.ts +1 -1
  23. package/templates/features/db/files/src/core/db/commands/reset.ts +1 -1
  24. package/templates/features/db/files/src/core/db/commands/seed.ts +1 -1
  25. package/templates/features/db/files/src/core/db/tests/reset.test.ts +1 -1
  26. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +2 -2
  27. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +2 -2
  28. package/templates/features/email/files/src/core/email/commands/send-test.ts +1 -1
  29. package/templates/features/jobs/files/src/apps/cron/index.ts +2 -2
  30. package/templates/features/jobs/files/src/apps/worker/index.ts +2 -2
  31. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
  32. package/templates/features/jobs/files/src/core/jobs/queue.ts +1 -1
  33. package/templates/features/jobs/files/src/core/jobs/redis.ts +1 -1
  34. package/templates/features/prisma/files+mysql/src/core/db/client.ts +2 -2
  35. package/templates/features/prisma/files+postgres/src/core/db/client.ts +2 -2
  36. package/templates/features/storage/files/src/core/storage/commands/check.ts +1 -1
  37. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +1 -1
  38. package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +1 -1
  39. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +1 -1
  40. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +2 -2
  41. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +2 -2
  42. package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +1 -1
  43. package/templates/versions.json +3 -1
  44. package/templates/features/base/files/src/core/commands/define.ts +0 -33
  45. package/templates/features/base/files/src/core/commands/runner.ts +0 -54
  46. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +0 -16
  47. package/templates/features/base/files/src/core/http/errors.ts +0 -79
  48. package/templates/features/base/files/src/core/http/health.ts +0 -25
  49. package/templates/features/base/files/src/core/http/middleware/error-handler.ts +0 -36
  50. package/templates/features/base/files/src/core/http/middleware/request-context.ts +0 -24
  51. package/templates/features/base/files/src/core/http/middleware/validation.ts +0 -17
  52. package/templates/features/base/files/src/core/http/response.ts +0 -72
  53. package/templates/features/base/files/src/core/http/server.ts +0 -15
  54. package/templates/features/base/files/src/core/process/lifecycle.ts +0 -34
package/dist/bin.mjs CHANGED
@@ -2,15 +2,16 @@
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
4
  import { spawn } from "node:child_process";
5
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
+ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
6
6
  import { dirname, join, relative, resolve, sep } from "node:path";
7
7
  import { glob } from "tinyglobby";
8
- import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, watch } from "node:fs";
9
9
  import { x } from "tinyexec";
10
10
  import { parseEnv } from "node:util";
11
11
  import * as p from "@clack/prompts";
12
- import { randomBytes } from "node:crypto";
12
+ import { createHash, randomBytes } from "node:crypto";
13
13
  import { stringify } from "yaml";
14
+ import { homedir, tmpdir } from "node:os";
14
15
  //#region \0rolldown/runtime.js
15
16
  var __defProp = Object.defineProperty;
16
17
  var __exportAll = (all, no_symbols) => {
@@ -248,7 +249,7 @@ async function sync(project = loadProject()) {
248
249
  const commands = await find(root, dirs.map((d) => `${d}/commands/*.ts`));
249
250
  await writeIfChanged(join(out, "commands.ts"), listModule(commands, {
250
251
  prefix: "command",
251
- typeImport: "import type { CommandDefinition } from \"#core/commands/define\";",
252
+ typeImport: "import type { CommandDefinition } from \"@tatara/core\";",
252
253
  typeName: "CommandDefinition",
253
254
  exportName: "commands"
254
255
  }), written);
@@ -663,11 +664,19 @@ function templateVersions() {
663
664
  versions ??= JSON.parse(readFileSync(resolve(TEMPLATES_DIR, "versions.json"), "utf8"));
664
665
  return versions;
665
666
  }
667
+ /** `TATARA_PACKAGE_SPECS='{"@tatara/core":"file:…"}'` installs local builds (e2e, bench). */
668
+ function packageSpecOverride(pkg) {
669
+ const raw = process.env.TATARA_PACKAGE_SPECS;
670
+ if (!raw) return void 0;
671
+ return JSON.parse(raw)[pkg];
672
+ }
666
673
  function versionOf(pkg) {
674
+ const override = packageSpecOverride(pkg);
675
+ if (override) return override;
667
676
  if (pkg === "@tatara/cli") return process.env.TATARA_CLI_SPEC ?? `^${cliVersion()}`;
668
677
  const version = templateVersions()[pkg];
669
678
  if (!version) throw new Error(`templates/versions.json has no version for "${pkg}"`);
670
- return version;
679
+ return pkg.startsWith("@tatara/") ? `^${version}` : version;
671
680
  }
672
681
  const authz = {
673
682
  id: "authz",
@@ -1042,12 +1051,12 @@ const i18n = {
1042
1051
  content: "app.use(detectLanguage);"
1043
1052
  },
1044
1053
  {
1045
- file: "src/core/http/middleware/error-handler.ts",
1054
+ file: "src/core/http/app.ts",
1046
1055
  slot: "imports",
1047
1056
  content: "import { t } from \"#core/i18n/index\";"
1048
1057
  },
1049
1058
  {
1050
- file: "src/core/http/middleware/error-handler.ts",
1059
+ file: "src/core/http/app.ts",
1051
1060
  slot: "error-message",
1052
1061
  content: "if (err.translatable) message = t(c.get(\"language\"), `errors.${err.code}`, message);"
1053
1062
  },
@@ -1159,6 +1168,11 @@ const betterAuth = {
1159
1168
  slot: "imports",
1160
1169
  content: "import { mergeAuthOpenApi } from \"#core/auth/openapi\";"
1161
1170
  },
1171
+ {
1172
+ file: "src/core/http/app.ts",
1173
+ slot: "expose-headers",
1174
+ content: "\"set-auth-token\","
1175
+ },
1162
1176
  {
1163
1177
  file: "src/core/http/app.ts",
1164
1178
  slot: "openapi",
@@ -1630,6 +1644,8 @@ const base = {
1630
1644
  kind: "base",
1631
1645
  label: "Hono API",
1632
1646
  dependencies: [
1647
+ "@tatara/core",
1648
+ "@tatara/http",
1633
1649
  "hono",
1634
1650
  "@hono/node-server",
1635
1651
  "@hono/zod-openapi",
@@ -2172,14 +2188,11 @@ function envFile(vars, kind) {
2172
2188
  }
2173
2189
  function envModule(vars) {
2174
2190
  return `import { createEnv } from "@t3-oss/env-core";
2191
+ import { createLocalDefault } from "@tatara/core";
2175
2192
  import { z } from "zod";
2176
2193
 
2177
- const production = process.env.NODE_ENV === "production";
2178
-
2179
2194
  /** A default for local development; production must set the value explicitly. */
2180
- function localDefault<T extends z.ZodType>(schema: T, value: z.input<T>): T {
2181
- return production ? schema : (schema.default(value as never) as unknown as T);
2182
- }
2195
+ const localDefault = createLocalDefault(process.env.NODE_ENV === "production");
2183
2196
 
2184
2197
  /** The only place that reads process.env. Add new variables here and to .env.example. */
2185
2198
  export const env = createEnv({
@@ -2259,8 +2272,9 @@ function composeFile(options, features, tokenValues) {
2259
2272
  function pnpmWorkspace(features) {
2260
2273
  const builds = [...new Set(features.flatMap((f) => f.allowBuilds ?? []))].sort();
2261
2274
  const lines = ["# Packages allowed to run install scripts."];
2262
- if (builds.length === 0) return `${lines.join("\n")}\nallowBuilds: {}\n`;
2263
- lines.push("allowBuilds:", ...builds.map((b) => ` "${b}": true`));
2275
+ if (builds.length === 0) lines.push("allowBuilds: {}");
2276
+ else lines.push("allowBuilds:", ...builds.map((b) => ` "${b}": true`));
2277
+ lines.push("# tatara's own packages are installed as soon as they are released", "minimumReleaseAgeExclude:", " - \"@tatara/*\"");
2264
2278
  return `${lines.join("\n")}\n`;
2265
2279
  }
2266
2280
  /** Folds applicable variants into each feature so later steps see one flat definition. */
@@ -2331,6 +2345,30 @@ function devEnvFromExample(options) {
2331
2345
  return envFile(dedupeEnv(effectiveFeatures(options), tokens(options)), "dev");
2332
2346
  }
2333
2347
  //#endregion
2348
+ //#region src/project/hashes.ts
2349
+ /** Framework files a package can replace; `tatara upgrade` tracks these. */
2350
+ const TRACKED_PATTERNS = [
2351
+ "src/core/**",
2352
+ "src/apps/**",
2353
+ "tests/helpers/**",
2354
+ "tests/setup.ts",
2355
+ "tests/global-setup.ts"
2356
+ ];
2357
+ function hashContent(text) {
2358
+ return `sha256-${createHash("sha256").update(text).digest("base64")}`;
2359
+ }
2360
+ /** Relative POSIX path → content hash, for every tracked file under `root`. */
2361
+ async function hashTrackedFiles(root) {
2362
+ const paths = await glob([...TRACKED_PATTERNS], {
2363
+ cwd: root,
2364
+ dot: true,
2365
+ onlyFiles: true
2366
+ });
2367
+ const out = {};
2368
+ for (const path of paths.sort()) out[path.replaceAll("\\", "/")] = hashContent(await readFile(join(root, path), "utf8"));
2369
+ return out;
2370
+ }
2371
+ //#endregion
2334
2372
  //#region src/engine/names.ts
2335
2373
  function words(input) {
2336
2374
  return input.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z0-9]+/g, " ").trim().toLowerCase().split(/\s+/).filter(Boolean);
@@ -2875,6 +2913,20 @@ async function newApp(dir, rawFlags) {
2875
2913
  });
2876
2914
  if (format.exitCode !== 0) console.warn(pc.yellow(`Formatting reported problems (please report this as a tatara bug):\n${format.stdout}`));
2877
2915
  }
2916
+ await recordGenerated(root);
2917
+ if (flags.install !== false) {
2918
+ const [exec, ...execArgs] = pm.exec.split(" ");
2919
+ await x(exec ?? "npx", [
2920
+ ...execArgs,
2921
+ "biome",
2922
+ "check",
2923
+ "--write",
2924
+ "tatara.json"
2925
+ ], {
2926
+ nodeOptions: { cwd: root },
2927
+ throwOnError: false
2928
+ });
2929
+ }
2878
2930
  if (flags.git !== false) {
2879
2931
  if ((await x("git", [
2880
2932
  "init",
@@ -2909,6 +2961,15 @@ async function newApp(dir, rawFlags) {
2909
2961
  else console.log(`Created ${root}\nNext:\n ${next.join("\n ")}`);
2910
2962
  return root;
2911
2963
  }
2964
+ async function recordGenerated(root) {
2965
+ const path = join(root, "tatara.json");
2966
+ const config = JSON.parse(await readFile(path, "utf8"));
2967
+ config.generated = {
2968
+ cli: cliVersion(),
2969
+ files: await hashTrackedFiles(root)
2970
+ };
2971
+ await writeFile(path, `${JSON.stringify(config, null, " ")}\n`);
2972
+ }
2912
2973
  //#endregion
2913
2974
  //#region src/commands/routes.ts
2914
2975
  /** Reads every app's OpenAPI document in-process and lists its routes. */
@@ -3016,6 +3077,515 @@ server.on("exit", () => process.exit(0));
3016
3077
  });
3017
3078
  }
3018
3079
  //#endregion
3080
+ //#region src/upgrade/baseline.ts
3081
+ const CACHE_DIR = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tatara", "baselines");
3082
+ /** `major.minor.patch` comparison; prereleases are not considered. */
3083
+ function compareVersions(a, b) {
3084
+ const pa = a.split(".").map(Number);
3085
+ const pb = b.split(".").map(Number);
3086
+ for (let i = 0; i < 3; i++) {
3087
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
3088
+ if (d !== 0) return d;
3089
+ }
3090
+ return 0;
3091
+ }
3092
+ /** Published @tatara/cli versions, without prereleases such as 0.0.0-stage. */
3093
+ async function releasedVersions() {
3094
+ const res = await fetch("https://registry.npmjs.org/@tatara%2fcli");
3095
+ if (!res.ok) throw new Error(`npm registry answered ${res.status}`);
3096
+ const body = await res.json();
3097
+ return Object.keys(body.versions ?? {}).filter((v) => /^\d+\.\d+\.\d+$/.test(v)).sort(compareVersions);
3098
+ }
3099
+ function newFlags(config) {
3100
+ return [
3101
+ "--orm",
3102
+ config.orm,
3103
+ "--db",
3104
+ config.database,
3105
+ "--auth",
3106
+ config.auth,
3107
+ ...config.authMethods.length ? ["--auth-methods", config.authMethods.join(",")] : [],
3108
+ ...config.addons.length ? ["--addons", config.addons.join(",")] : [],
3109
+ "--pm",
3110
+ config.packageManager
3111
+ ];
3112
+ }
3113
+ /** Templates contain the app name, so the release must render an app with the same name. */
3114
+ function renderTarget(base, config) {
3115
+ return join(base, config.name);
3116
+ }
3117
+ /** What `@tatara/cli@<version> new` writes for these options (not installed, not formatted). */
3118
+ async function renderRelease(version, config) {
3119
+ const dir = renderTarget(await mkdtemp(join(tmpdir(), `tatara-${version}-`)), config);
3120
+ const res = await x("npx", [
3121
+ "--yes",
3122
+ "--package",
3123
+ `@tatara/cli@${version}`,
3124
+ "--",
3125
+ "tatara",
3126
+ "new",
3127
+ dir,
3128
+ ...newFlags(config),
3129
+ "--yes",
3130
+ "--no-install",
3131
+ "--no-git"
3132
+ ], {
3133
+ throwOnError: false,
3134
+ nodeOptions: {
3135
+ cwd: dirname(dir),
3136
+ env: {
3137
+ ...process.env,
3138
+ TATARA_CLI_SPEC: void 0
3139
+ }
3140
+ }
3141
+ });
3142
+ if (res.exitCode !== 0) throw new Error(`@tatara/cli@${version} could not generate these options: ${res.stderr.slice(-300)}`);
3143
+ return dir;
3144
+ }
3145
+ function digest(text) {
3146
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
3147
+ }
3148
+ function baselineCacheKey(version, config, biome) {
3149
+ const inputs = JSON.stringify({
3150
+ name: config.name,
3151
+ flags: newFlags(config)
3152
+ });
3153
+ const format = biome ? `-fmt-${digest(`${biome.version}\n${biome.config}`)}` : "";
3154
+ return `${version}-${digest(inputs)}${format}`;
3155
+ }
3156
+ async function appBiome(appRoot) {
3157
+ const configFile = join(appRoot, "biome.json");
3158
+ const pkgFile = join(appRoot, "node_modules", "@biomejs", "biome", "package.json");
3159
+ if (!existsSync(configFile) || !existsSync(pkgFile)) return void 0;
3160
+ const { version } = JSON.parse(await readFile(pkgFile, "utf8"));
3161
+ return {
3162
+ config: await readFile(configFile, "utf8"),
3163
+ version
3164
+ };
3165
+ }
3166
+ /**
3167
+ * Hashes of the tracked files `@tatara/cli@<version>` generates for `config`. With `formatWith`
3168
+ * (an app root with Biome installed), files are first formatted with that app's Biome and
3169
+ * biome.json, so formatting differences don't count as changes. Cached on disk.
3170
+ */
3171
+ async function releaseBaseline(version, config, opts = {}) {
3172
+ const biome = opts.formatWith ? await appBiome(opts.formatWith) : void 0;
3173
+ const cacheFile = join(CACHE_DIR, `${baselineCacheKey(version, config, biome)}.json`);
3174
+ if (existsSync(cacheFile)) return JSON.parse(await readFile(cacheFile, "utf8"));
3175
+ const dir = await renderRelease(version, config);
3176
+ if (biome && opts.formatWith) {
3177
+ await cp(join(opts.formatWith, "biome.json"), join(dir, "biome.json"));
3178
+ const bin = join(opts.formatWith, "node_modules", ".bin", "biome");
3179
+ const res = await x(bin, [
3180
+ "check",
3181
+ "--write",
3182
+ "."
3183
+ ], {
3184
+ throwOnError: false,
3185
+ nodeOptions: { cwd: dir }
3186
+ });
3187
+ if (res.exitCode !== 0 && res.exitCode !== 1) throw new Error(`could not format the ${version} baseline with the app's Biome: ${res.stderr.slice(-300)}`);
3188
+ }
3189
+ const hashes = await hashTrackedFiles(dir);
3190
+ mkdirSync(CACHE_DIR, { recursive: true });
3191
+ await writeFile(cacheFile, JSON.stringify(hashes));
3192
+ return hashes;
3193
+ }
3194
+ function createBaseline(project, deps) {
3195
+ const resolved = {
3196
+ releasedVersions,
3197
+ releaseBaseline: (version, config) => releaseBaseline(version, config, { formatWith: project.root }),
3198
+ cliVersion: cliVersion(),
3199
+ ...deps
3200
+ };
3201
+ const recorded = project.config.generated?.files ?? {};
3202
+ let releases;
3203
+ async function loadReleases() {
3204
+ const from = project.config.version;
3205
+ const versions = (await resolved.releasedVersions()).filter((v) => compareVersions(v, from) >= 0 && compareVersions(v, resolved.cliVersion) <= 0);
3206
+ const out = [];
3207
+ for (const v of versions) out.push(await resolved.releaseBaseline(v, project.config).catch(() => ({})));
3208
+ return out;
3209
+ }
3210
+ return { async isUnchanged(path, content) {
3211
+ const hash = hashContent(content);
3212
+ if (recorded[path] === hash) return true;
3213
+ releases ??= loadReleases();
3214
+ return (await releases).some((files) => files[path] === hash);
3215
+ } };
3216
+ }
3217
+ //#endregion
3218
+ //#region src/upgrade/codemods/core-package.ts
3219
+ const CORE = "@tatara/core";
3220
+ /** Files @tatara/core replaces, with the import specifier apps used for each. */
3221
+ const MOVED$1 = {
3222
+ "src/core/commands/define.ts": "#core/commands/define",
3223
+ "src/core/commands/runner.ts": "#core/commands/runner",
3224
+ "src/core/process/lifecycle.ts": "#core/process/lifecycle",
3225
+ "src/core/http/health.ts": "#core/http/health"
3226
+ };
3227
+ const ENV = "src/core/config/env.ts";
3228
+ const LOGGER = "src/core/logging/logger.ts";
3229
+ const CLI_ENTRY = "src/apps/cli/index.ts";
3230
+ const SIGNAL_FILES = [
3231
+ "src/core/http/server.ts",
3232
+ "src/apps/worker/index.ts",
3233
+ "src/apps/cron/index.ts"
3234
+ ];
3235
+ const LOGGER_IMPORT = "import { logger } from \"#core/logging/logger\";";
3236
+ const OLD_HELPER = /const production = process\.env\.NODE_ENV === "production";\n\n(\/\*\*[^\n]*\*\/\n)?function localDefault<T extends z\.ZodType>\(schema: T, value: z\.input<T>\): T \{\n\treturn production \? schema : \(schema\.default\(value as never\) as unknown as T\);\n\}\n/;
3237
+ async function exists(ctx, path) {
3238
+ return ctx.read(path).catch(() => void 0);
3239
+ }
3240
+ /** Adds `line` after the last import, unless the file already has it. */
3241
+ function withImport(text, line) {
3242
+ if (text.includes(line)) return text;
3243
+ const last = [...text.matchAll(/^import .*;$/gm)].at(-1);
3244
+ if (!last || last.index === void 0) return `${line}\n${text}`;
3245
+ const end = last.index + last[0].length;
3246
+ return `${text.slice(0, end)}\n${line}${text.slice(end)}`;
3247
+ }
3248
+ async function rewriteEnv(ctx) {
3249
+ const text = await exists(ctx, ENV);
3250
+ if (!text || !OLD_HELPER.test(text)) return;
3251
+ const next = withImport(text.replace(OLD_HELPER, "/** A default for local development; production must set the value explicitly. */\nconst localDefault = createLocalDefault(process.env.NODE_ENV === \"production\");\n"), "import { createLocalDefault } from \"@tatara/core\";");
3252
+ await ctx.write(ENV, next);
3253
+ }
3254
+ /** Only call sites of functions that now come from @tatara/core take the logger argument. */
3255
+ async function passLogger(ctx, gone) {
3256
+ const runnerGone = gone.includes(MOVED$1["src/core/commands/runner.ts"]);
3257
+ const lifecycleGone = gone.includes(MOVED$1["src/core/process/lifecycle.ts"]);
3258
+ const cli = await exists(ctx, CLI_ENTRY);
3259
+ if (cli) {
3260
+ let next = cli;
3261
+ if (runnerGone) next = next.replace("runCommand(commands, process.argv.slice(2));", "runCommand(commands, process.argv.slice(2), logger);");
3262
+ if (lifecycleGone) next = next.replace("await shutdown(code);", "await shutdown(code, logger);");
3263
+ if (next !== cli) await ctx.write(CLI_ENTRY, withImport(next, LOGGER_IMPORT));
3264
+ }
3265
+ if (!lifecycleGone) return;
3266
+ for (const path of SIGNAL_FILES) {
3267
+ const text = await exists(ctx, path);
3268
+ if (text?.includes("handleSignals();")) await ctx.write(path, withImport(text.replace("handleSignals();", "handleSignals(logger);"), LOGGER_IMPORT));
3269
+ }
3270
+ }
3271
+ const WORKSPACE = "pnpm-workspace.yaml";
3272
+ const EXCLUDE_ENTRY = " - \"@tatara/*\"";
3273
+ /** pnpm refuses packages younger than its minimum release age; tatara's own are exempt. */
3274
+ async function allowFreshReleases(ctx) {
3275
+ if (ctx.project.config.packageManager !== "pnpm") return;
3276
+ const text = await exists(ctx, WORKSPACE);
3277
+ if (text === void 0 || /["']@tatara\/\*["']/.test(text)) return;
3278
+ const lines = text.replace(/\n$/, "").split("\n");
3279
+ const key = lines.findIndex((line) => line.trim() === "minimumReleaseAgeExclude:");
3280
+ if (key === -1) lines.push("minimumReleaseAgeExclude:", EXCLUDE_ENTRY);
3281
+ else lines.splice(key + 1, 0, EXCLUDE_ENTRY);
3282
+ await ctx.write(WORKSPACE, `${lines.join("\n")}\n`);
3283
+ }
3284
+ async function useLoggerOptions(ctx) {
3285
+ const text = await exists(ctx, LOGGER);
3286
+ if (!text || text.includes("loggerOptions")) return;
3287
+ if (!await ctx.baseline.isUnchanged(LOGGER, text)) {
3288
+ ctx.report.notes.push(`${LOGGER} is customised and was left as is. It must still export a pino \`logger\`; @tatara/core's loggerOptions() gives the standard options.`);
3289
+ return;
3290
+ }
3291
+ ctx.report.notes.push(`${LOGGER} now uses @tatara/core's loggerOptions: in production the log text is in \`message\` (was \`msg\`) and each line has a Cloud Logging \`severity\`. Update log queries or alerts that read \`msg\`.`);
3292
+ await ctx.write(LOGGER, `import { loggerOptions } from "@tatara/core";
3293
+ import { pino } from "pino";
3294
+ import { env } from "#core/config/env";
3295
+
3296
+ export const logger = pino(
3297
+ loggerOptions({ env: env.NODE_ENV, level: env.LOG_LEVEL, service: "${ctx.project.config.name}" }),
3298
+ );
3299
+
3300
+ export type Logger = typeof logger;
3301
+ `);
3302
+ }
3303
+ const corePackage = {
3304
+ version: "0.2.0",
3305
+ id: "core-package",
3306
+ description: "Use @tatara/core for commands, flags, lifecycle, readiness, logger options and localDefault",
3307
+ async run(ctx) {
3308
+ await ctx.addDependency(CORE, versionOf(CORE));
3309
+ const gone = [];
3310
+ for (const [path, specifier] of Object.entries(MOVED$1)) if (await ctx.removeIfUnchanged(path) === "kept") ctx.report.notes.push(`${path} is customised; its imports still point at it. Move the change into the app or eject.`);
3311
+ else gone.push(specifier);
3312
+ for (const specifier of gone) await ctx.rewriteImports(specifier, CORE);
3313
+ await rewriteEnv(ctx);
3314
+ await passLogger(ctx, gone);
3315
+ await allowFreshReleases(ctx);
3316
+ await useLoggerOptions(ctx);
3317
+ }
3318
+ };
3319
+ //#endregion
3320
+ //#region src/upgrade/codemods/http-package.ts
3321
+ const HTTP = "@tatara/http";
3322
+ /** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
3323
+ const APP = "src/core/http/app.ts";
3324
+ const ROUTES = "src/core/http/routes.ts";
3325
+ const SERVERS = ["src/apps/api/server.ts", "src/apps/admin/server.ts"];
3326
+ /** Files @tatara/http replaces: import specifier, and the wiring files that import them. */
3327
+ const MOVED = [
3328
+ {
3329
+ path: "src/core/http/errors.ts",
3330
+ specifier: "#core/http/errors",
3331
+ usedBy: []
3332
+ },
3333
+ {
3334
+ path: "src/core/http/response.ts",
3335
+ specifier: "#core/http/response",
3336
+ usedBy: []
3337
+ },
3338
+ {
3339
+ path: "src/core/http/middleware/validation.ts",
3340
+ specifier: "#core/http/middleware/validation",
3341
+ usedBy: [APP, ROUTES]
3342
+ },
3343
+ {
3344
+ path: "src/core/http/middleware/error-handler.ts",
3345
+ specifier: "#core/http/middleware/error-handler",
3346
+ usedBy: [APP]
3347
+ },
3348
+ {
3349
+ path: "src/core/http/middleware/request-context.ts",
3350
+ specifier: "#core/http/middleware/request-context",
3351
+ usedBy: [APP]
3352
+ },
3353
+ {
3354
+ path: "src/core/http/server.ts",
3355
+ specifier: "#core/http/server",
3356
+ usedBy: SERVERS
3357
+ }
3358
+ ];
3359
+ async function read(ctx, path) {
3360
+ return ctx.read(path).catch(() => void 0);
3361
+ }
3362
+ /** Files outside the group that import one of the moved files (they would break). */
3363
+ async function outsideImporters(ctx) {
3364
+ const group = /* @__PURE__ */ new Set([
3365
+ APP,
3366
+ ROUTES,
3367
+ ...SERVERS,
3368
+ ...MOVED.map((m) => m.path)
3369
+ ]);
3370
+ const specifiers = MOVED.filter((m) => m.usedBy.length > 0).map((m) => m.specifier);
3371
+ const pattern = new RegExp(`["'](${specifiers.map((s) => s.replace(/[/.]/g, "\\$&")).join("|")})["']`);
3372
+ const out = [];
3373
+ for (const path of await glob(["src/**/*.ts", "tests/**/*.ts"], {
3374
+ cwd: ctx.project.root,
3375
+ ignore: ["src/generated/**"]
3376
+ })) {
3377
+ if (group.has(path)) continue;
3378
+ if (pattern.test(await ctx.read(path))) out.push(path);
3379
+ }
3380
+ return out;
3381
+ }
3382
+ //#endregion
3383
+ //#region src/upgrade/codemods.ts
3384
+ /** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
3385
+ const CODEMODS = [corePackage, {
3386
+ version: "0.3.0",
3387
+ id: "http-package",
3388
+ description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
3389
+ async run(ctx) {
3390
+ await ctx.addDependency(HTTP, versionOf(HTTP));
3391
+ const blockers = [];
3392
+ const replace = [];
3393
+ for (const path of [
3394
+ APP,
3395
+ ROUTES,
3396
+ ...SERVERS
3397
+ ]) {
3398
+ const text = await read(ctx, path);
3399
+ if (text === void 0) continue;
3400
+ if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
3401
+ else blockers.push(`${path} is customised`);
3402
+ }
3403
+ const present = [];
3404
+ for (const entry of MOVED) {
3405
+ const text = await read(ctx, entry.path);
3406
+ if (text === void 0) continue;
3407
+ present.push(entry);
3408
+ if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
3409
+ }
3410
+ for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
3411
+ if (blockers.length > 0) {
3412
+ ctx.report.notes.push(`Nothing in src/core/http was changed: ${blockers.join("; ")}. Move these onto @tatara/http (createApp, mountRoutes, serveOpenApi, handleErrors, startServer and its error classes) by hand, then delete the old files.`);
3413
+ return;
3414
+ }
3415
+ for (const path of replace) {
3416
+ const next = await ctx.renderCurrent(path);
3417
+ if (next !== void 0) await ctx.write(path, next);
3418
+ }
3419
+ for (const entry of present) await ctx.removeIfUnchanged(entry.path);
3420
+ for (const specifier of [
3421
+ "#core/http/errors",
3422
+ "#core/http/response",
3423
+ "#core/http/middleware/validation"
3424
+ ]) await ctx.rewriteImports(specifier, HTTP);
3425
+ }
3426
+ }];
3427
+ //#endregion
3428
+ //#region src/upgrade/context.ts
3429
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3430
+ function createContext(project, baseline, opts = {}) {
3431
+ const report = {
3432
+ ran: [],
3433
+ written: [],
3434
+ regenerated: [],
3435
+ removed: [],
3436
+ kept: [],
3437
+ notes: []
3438
+ };
3439
+ const abs = (path) => join(project.root, path);
3440
+ let rendered;
3441
+ const write = async (path, text) => {
3442
+ if (!report.written.includes(path)) {
3443
+ report.written.push(path);
3444
+ const before = existsSync(abs(path)) ? await readFile(abs(path), "utf8") : void 0;
3445
+ if (before === void 0 || await baseline.isUnchanged(path, before)) report.regenerated.push(path);
3446
+ }
3447
+ if (opts.dryRun) return;
3448
+ await mkdir(dirname(abs(path)), { recursive: true });
3449
+ await writeFile(abs(path), text);
3450
+ };
3451
+ return {
3452
+ project,
3453
+ baseline,
3454
+ report,
3455
+ read: (path) => readFile(abs(path), "utf8"),
3456
+ write,
3457
+ async removeIfUnchanged(path) {
3458
+ if (!existsSync(abs(path))) return "missing";
3459
+ const content = await readFile(abs(path), "utf8");
3460
+ if (await baseline.isUnchanged(path, content)) {
3461
+ if (!opts.dryRun) await rm(abs(path));
3462
+ report.removed.push(path);
3463
+ return "removed";
3464
+ }
3465
+ const diff = content.split("\n").map((line) => `+${line}`).join("\n");
3466
+ report.kept.push({
3467
+ path,
3468
+ diff
3469
+ });
3470
+ return "kept";
3471
+ },
3472
+ async rewriteImports(from, to) {
3473
+ const pattern = new RegExp(`(["'])${escapeRegExp(from)}(?=(?:/[^"']*)?\\1)`, "g");
3474
+ let changed = 0;
3475
+ for (const path of await glob(["src/**/*.ts", "tests/**/*.ts"], {
3476
+ cwd: project.root,
3477
+ ignore: ["src/generated/**"]
3478
+ })) {
3479
+ const text = await readFile(abs(path), "utf8");
3480
+ const next = text.replace(pattern, `$1${to}`);
3481
+ if (next !== text) {
3482
+ await write(path, next);
3483
+ changed++;
3484
+ }
3485
+ }
3486
+ return changed;
3487
+ },
3488
+ async renderCurrent(path) {
3489
+ const { name, orm, database, auth, authMethods, addons, packageManager } = project.config;
3490
+ rendered ??= renderApp({
3491
+ name,
3492
+ orm,
3493
+ database,
3494
+ auth,
3495
+ authMethods,
3496
+ addons,
3497
+ packageManager
3498
+ });
3499
+ return (await rendered).get(path);
3500
+ },
3501
+ async addDependency(name, range, { dev = false } = {}) {
3502
+ const pkg = JSON.parse(await readFile(abs("package.json"), "utf8"));
3503
+ const key = dev ? "devDependencies" : "dependencies";
3504
+ pkg[key] = {
3505
+ ...pkg[key],
3506
+ [name]: range
3507
+ };
3508
+ await write("package.json", `${JSON.stringify(pkg, null, " ")}\n`);
3509
+ }
3510
+ };
3511
+ }
3512
+ //#endregion
3513
+ //#region src/commands/upgrade.ts
3514
+ async function biomeFormat(file, project) {
3515
+ const biome = join(project.root, "node_modules", ".bin", "biome");
3516
+ if (!existsSync(biome)) return;
3517
+ await x(biome, [
3518
+ "check",
3519
+ "--write",
3520
+ file
3521
+ ], {
3522
+ throwOnError: false,
3523
+ nodeOptions: { cwd: project.root }
3524
+ });
3525
+ }
3526
+ /**
3527
+ * The recorded hashes stay what tatara generated: files the app changed keep their old (generated)
3528
+ * hash, and only files this upgrade wrote or removed are updated.
3529
+ */
3530
+ async function nextRecord(project, report) {
3531
+ const record = { ...project.config.generated?.files ?? {} };
3532
+ for (const path of report.removed) delete record[path];
3533
+ const current = await hashTrackedFiles(project.root);
3534
+ for (const path of report.regenerated) {
3535
+ const hash = current[path];
3536
+ if (hash) record[path] = hash;
3537
+ }
3538
+ return record;
3539
+ }
3540
+ /**
3541
+ * Runs the codemods between the app's tatara version and this CLI's, then syncs and records the
3542
+ * new version and file hashes. A failing codemod stops the upgrade before the version is recorded.
3543
+ */
3544
+ async function upgrade(project, opts = {}) {
3545
+ const target = opts.cliVersion ?? cliVersion();
3546
+ const from = project.config.version;
3547
+ const ctx = createContext(project, opts.baseline ?? createBaseline(project), { dryRun: opts.dryRun });
3548
+ const due = [...opts.codemods ?? CODEMODS].filter((m) => compareVersions(m.version, from) > 0 && compareVersions(m.version, target) <= 0).sort((a, b) => compareVersions(a.version, b.version));
3549
+ for (const mod of due) {
3550
+ try {
3551
+ await mod.run(ctx);
3552
+ } catch (error) {
3553
+ throw new Error(`codemod ${mod.id} (${mod.version}) failed: ${error.message}`, { cause: error });
3554
+ }
3555
+ ctx.report.ran.push(mod.id);
3556
+ }
3557
+ if (opts.dryRun) return ctx.report;
3558
+ const format = opts.formatFile ?? biomeFormat;
3559
+ for (const path of ctx.report.written) await format(path, project);
3560
+ await (opts.runSync ?? sync)(project);
3561
+ const path = join(project.root, "tatara.json");
3562
+ const config = JSON.parse(await readFile(path, "utf8"));
3563
+ const next = {
3564
+ ...config,
3565
+ version: target,
3566
+ generated: {
3567
+ cli: target,
3568
+ files: await nextRecord(project, ctx.report)
3569
+ }
3570
+ };
3571
+ if (JSON.stringify(next) !== JSON.stringify(config)) {
3572
+ await writeFile(path, `${JSON.stringify(next, null, " ")}\n`);
3573
+ await format("tatara.json", project);
3574
+ }
3575
+ return ctx.report;
3576
+ }
3577
+ function printUpgrade(report, from, to) {
3578
+ if (report.ran.length === 0 && report.removed.length === 0 && report.kept.length === 0) {
3579
+ console.log(pc.green(`nothing to upgrade (${from} → ${to})`));
3580
+ return;
3581
+ }
3582
+ console.log(pc.bold(`tatara upgrade ${from} → ${to}`));
3583
+ for (const id of report.ran) console.log(` ran ${id}`);
3584
+ for (const path of report.removed) console.log(pc.dim(` removed ${path} (now provided by a package)`));
3585
+ for (const { path } of report.kept) console.log(pc.yellow(` kept ${path}: changed in this app; move the change into options or eject`));
3586
+ for (const note of report.notes) console.log(` ${note}`);
3587
+ }
3588
+ //#endregion
3019
3589
  //#region src/bin.ts
3020
3590
  const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
3021
3591
  program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | none (keycloak = better-auth with --auth-methods keycloak)").option("--auth-methods <list>", "email-otp,phone-otp,password,orgs (better-auth)").option("--addons <list>", "authz,jobs,admin,storage,email,i18n").option("--pm <pm>", "pnpm | npm | bun").option("-y, --yes", "skip prompts and use flags/defaults").option("--no-install", "do not install dependencies").option("--no-git", "do not initialise git").action(async (dir, flags) => {
@@ -3087,6 +3657,12 @@ program.command("routes").description("List every route and what protects it").o
3087
3657
  if (flags.json) console.log(JSON.stringify(routes, null, 2));
3088
3658
  else printRoutes(routes);
3089
3659
  });
3660
+ program.command("upgrade").description("Run this CLI version's codemods on the app, then sync and verify").option("--dry-run", "show what would change without writing").option("--no-verify", "skip pnpm verify at the end").action(async (flags) => {
3661
+ const project = loadProject();
3662
+ const from = project.config.version;
3663
+ printUpgrade(await upgrade(project, { dryRun: flags.dryRun }), from, cliVersion());
3664
+ if (!flags.dryRun && flags.verify !== false && !await verify(loadProject())) process.exitCode = 1;
3665
+ });
3090
3666
  program.command("doctor").description("Check the toolchain, services, env and layout").option("--json", "machine-readable output").action(async (flags) => {
3091
3667
  const checks = await doctor(loadProject());
3092
3668
  if (flags.json) console.log(JSON.stringify(checks, null, 2));