@tatara/cli 0.1.2 → 0.2.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 (33) hide show
  1. package/dist/bin.mjs +459 -13
  2. package/package.json +10 -1
  3. package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +1 -1
  4. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +1 -1
  5. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +1 -1
  6. package/templates/features/base/files/AGENTS.md +1 -0
  7. package/templates/features/base/files/README.md +1 -1
  8. package/templates/features/base/files/src/apps/cli/index.ts +4 -4
  9. package/templates/features/base/files/src/core/http/app.ts +1 -1
  10. package/templates/features/base/files/src/core/http/server.ts +2 -2
  11. package/templates/features/base/files/src/core/logging/logger.ts +4 -12
  12. package/templates/features/db/files/src/core/db/commands/migrate.ts +1 -1
  13. package/templates/features/db/files/src/core/db/commands/reset.ts +1 -1
  14. package/templates/features/db/files/src/core/db/commands/seed.ts +1 -1
  15. package/templates/features/db/files/src/core/db/tests/reset.test.ts +1 -1
  16. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +2 -2
  17. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +2 -2
  18. package/templates/features/email/files/src/core/email/commands/send-test.ts +1 -1
  19. package/templates/features/jobs/files/src/apps/cron/index.ts +2 -2
  20. package/templates/features/jobs/files/src/apps/worker/index.ts +2 -2
  21. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
  22. package/templates/features/jobs/files/src/core/jobs/queue.ts +1 -1
  23. package/templates/features/jobs/files/src/core/jobs/redis.ts +1 -1
  24. package/templates/features/prisma/files+mysql/src/core/db/client.ts +2 -2
  25. package/templates/features/prisma/files+postgres/src/core/db/client.ts +2 -2
  26. package/templates/features/storage/files/src/core/storage/commands/check.ts +1 -1
  27. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +1 -1
  28. package/templates/versions.json +2 -1
  29. package/templates/features/base/files/src/core/commands/define.ts +0 -33
  30. package/templates/features/base/files/src/core/commands/runner.ts +0 -54
  31. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +0 -16
  32. package/templates/features/base/files/src/core/http/health.ts +0 -25
  33. 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",
@@ -1630,6 +1639,7 @@ const base = {
1630
1639
  kind: "base",
1631
1640
  label: "Hono API",
1632
1641
  dependencies: [
1642
+ "@tatara/core",
1633
1643
  "hono",
1634
1644
  "@hono/node-server",
1635
1645
  "@hono/zod-openapi",
@@ -2172,14 +2182,11 @@ function envFile(vars, kind) {
2172
2182
  }
2173
2183
  function envModule(vars) {
2174
2184
  return `import { createEnv } from "@t3-oss/env-core";
2185
+ import { createLocalDefault } from "@tatara/core";
2175
2186
  import { z } from "zod";
2176
2187
 
2177
- const production = process.env.NODE_ENV === "production";
2178
-
2179
2188
  /** 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
- }
2189
+ const localDefault = createLocalDefault(process.env.NODE_ENV === "production");
2183
2190
 
2184
2191
  /** The only place that reads process.env. Add new variables here and to .env.example. */
2185
2192
  export const env = createEnv({
@@ -2259,8 +2266,9 @@ function composeFile(options, features, tokenValues) {
2259
2266
  function pnpmWorkspace(features) {
2260
2267
  const builds = [...new Set(features.flatMap((f) => f.allowBuilds ?? []))].sort();
2261
2268
  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`));
2269
+ if (builds.length === 0) lines.push("allowBuilds: {}");
2270
+ else lines.push("allowBuilds:", ...builds.map((b) => ` "${b}": true`));
2271
+ lines.push("# tatara's own packages are installed as soon as they are released", "minimumReleaseAgeExclude:", " - \"@tatara/*\"");
2264
2272
  return `${lines.join("\n")}\n`;
2265
2273
  }
2266
2274
  /** Folds applicable variants into each feature so later steps see one flat definition. */
@@ -2306,7 +2314,7 @@ async function renderApp(options, generate = {}) {
2306
2314
  if (compose) files.set("compose.yaml", compose);
2307
2315
  if (options.packageManager === "pnpm") files.set("pnpm-workspace.yaml", pnpmWorkspace(features));
2308
2316
  const config = {
2309
- $schema: "https://raw.githubusercontent.com/mohammed-bageri/tatara/main/schema/tatara.json",
2317
+ $schema: "https://raw.githubusercontent.com/mohammed-bageri/tatara-cli/main/schema/tatara.json",
2310
2318
  version: cliVersion(),
2311
2319
  name: options.name,
2312
2320
  orm: options.orm,
@@ -2331,6 +2339,30 @@ function devEnvFromExample(options) {
2331
2339
  return envFile(dedupeEnv(effectiveFeatures(options), tokens(options)), "dev");
2332
2340
  }
2333
2341
  //#endregion
2342
+ //#region src/project/hashes.ts
2343
+ /** Framework files a package can replace; `tatara upgrade` tracks these. */
2344
+ const TRACKED_PATTERNS = [
2345
+ "src/core/**",
2346
+ "src/apps/**",
2347
+ "tests/helpers/**",
2348
+ "tests/setup.ts",
2349
+ "tests/global-setup.ts"
2350
+ ];
2351
+ function hashContent(text) {
2352
+ return `sha256-${createHash("sha256").update(text).digest("base64")}`;
2353
+ }
2354
+ /** Relative POSIX path → content hash, for every tracked file under `root`. */
2355
+ async function hashTrackedFiles(root) {
2356
+ const paths = await glob([...TRACKED_PATTERNS], {
2357
+ cwd: root,
2358
+ dot: true,
2359
+ onlyFiles: true
2360
+ });
2361
+ const out = {};
2362
+ for (const path of paths.sort()) out[path.replaceAll("\\", "/")] = hashContent(await readFile(join(root, path), "utf8"));
2363
+ return out;
2364
+ }
2365
+ //#endregion
2334
2366
  //#region src/engine/names.ts
2335
2367
  function words(input) {
2336
2368
  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 +2907,20 @@ async function newApp(dir, rawFlags) {
2875
2907
  });
2876
2908
  if (format.exitCode !== 0) console.warn(pc.yellow(`Formatting reported problems (please report this as a tatara bug):\n${format.stdout}`));
2877
2909
  }
2910
+ await recordGenerated(root);
2911
+ if (flags.install !== false) {
2912
+ const [exec, ...execArgs] = pm.exec.split(" ");
2913
+ await x(exec ?? "npx", [
2914
+ ...execArgs,
2915
+ "biome",
2916
+ "check",
2917
+ "--write",
2918
+ "tatara.json"
2919
+ ], {
2920
+ nodeOptions: { cwd: root },
2921
+ throwOnError: false
2922
+ });
2923
+ }
2878
2924
  if (flags.git !== false) {
2879
2925
  if ((await x("git", [
2880
2926
  "init",
@@ -2909,6 +2955,15 @@ async function newApp(dir, rawFlags) {
2909
2955
  else console.log(`Created ${root}\nNext:\n ${next.join("\n ")}`);
2910
2956
  return root;
2911
2957
  }
2958
+ async function recordGenerated(root) {
2959
+ const path = join(root, "tatara.json");
2960
+ const config = JSON.parse(await readFile(path, "utf8"));
2961
+ config.generated = {
2962
+ cli: cliVersion(),
2963
+ files: await hashTrackedFiles(root)
2964
+ };
2965
+ await writeFile(path, `${JSON.stringify(config, null, " ")}\n`);
2966
+ }
2912
2967
  //#endregion
2913
2968
  //#region src/commands/routes.ts
2914
2969
  /** Reads every app's OpenAPI document in-process and lists its routes. */
@@ -3016,6 +3071,391 @@ server.on("exit", () => process.exit(0));
3016
3071
  });
3017
3072
  }
3018
3073
  //#endregion
3074
+ //#region src/upgrade/baseline.ts
3075
+ const CACHE_DIR = join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tatara", "baselines");
3076
+ /** `major.minor.patch` comparison; prereleases are not considered. */
3077
+ function compareVersions(a, b) {
3078
+ const pa = a.split(".").map(Number);
3079
+ const pb = b.split(".").map(Number);
3080
+ for (let i = 0; i < 3; i++) {
3081
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
3082
+ if (d !== 0) return d;
3083
+ }
3084
+ return 0;
3085
+ }
3086
+ /** Published @tatara/cli versions, without prereleases such as 0.0.0-stage. */
3087
+ async function releasedVersions() {
3088
+ const res = await fetch("https://registry.npmjs.org/@tatara%2fcli");
3089
+ if (!res.ok) throw new Error(`npm registry answered ${res.status}`);
3090
+ const body = await res.json();
3091
+ return Object.keys(body.versions ?? {}).filter((v) => /^\d+\.\d+\.\d+$/.test(v)).sort(compareVersions);
3092
+ }
3093
+ function newFlags(config) {
3094
+ return [
3095
+ "--orm",
3096
+ config.orm,
3097
+ "--db",
3098
+ config.database,
3099
+ "--auth",
3100
+ config.auth,
3101
+ ...config.authMethods.length ? ["--auth-methods", config.authMethods.join(",")] : [],
3102
+ ...config.addons.length ? ["--addons", config.addons.join(",")] : [],
3103
+ "--pm",
3104
+ config.packageManager
3105
+ ];
3106
+ }
3107
+ /** Templates contain the app name, so the release must render an app with the same name. */
3108
+ function renderTarget(base, config) {
3109
+ return join(base, config.name);
3110
+ }
3111
+ /** What `@tatara/cli@<version> new` writes for these options (not installed, not formatted). */
3112
+ async function renderRelease(version, config) {
3113
+ const dir = renderTarget(await mkdtemp(join(tmpdir(), `tatara-${version}-`)), config);
3114
+ const res = await x("npx", [
3115
+ "--yes",
3116
+ "--package",
3117
+ `@tatara/cli@${version}`,
3118
+ "--",
3119
+ "tatara",
3120
+ "new",
3121
+ dir,
3122
+ ...newFlags(config),
3123
+ "--yes",
3124
+ "--no-install",
3125
+ "--no-git"
3126
+ ], {
3127
+ throwOnError: false,
3128
+ nodeOptions: {
3129
+ cwd: dirname(dir),
3130
+ env: {
3131
+ ...process.env,
3132
+ TATARA_CLI_SPEC: void 0
3133
+ }
3134
+ }
3135
+ });
3136
+ if (res.exitCode !== 0) throw new Error(`@tatara/cli@${version} could not generate these options: ${res.stderr.slice(-300)}`);
3137
+ return dir;
3138
+ }
3139
+ function digest(text) {
3140
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
3141
+ }
3142
+ function baselineCacheKey(version, config, biome) {
3143
+ const inputs = JSON.stringify({
3144
+ name: config.name,
3145
+ flags: newFlags(config)
3146
+ });
3147
+ const format = biome ? `-fmt-${digest(`${biome.version}\n${biome.config}`)}` : "";
3148
+ return `${version}-${digest(inputs)}${format}`;
3149
+ }
3150
+ async function appBiome(appRoot) {
3151
+ const configFile = join(appRoot, "biome.json");
3152
+ const pkgFile = join(appRoot, "node_modules", "@biomejs", "biome", "package.json");
3153
+ if (!existsSync(configFile) || !existsSync(pkgFile)) return void 0;
3154
+ const { version } = JSON.parse(await readFile(pkgFile, "utf8"));
3155
+ return {
3156
+ config: await readFile(configFile, "utf8"),
3157
+ version
3158
+ };
3159
+ }
3160
+ /**
3161
+ * Hashes of the tracked files `@tatara/cli@<version>` generates for `config`. With `formatWith`
3162
+ * (an app root with Biome installed), files are first formatted with that app's Biome and
3163
+ * biome.json, so formatting differences don't count as changes. Cached on disk.
3164
+ */
3165
+ async function releaseBaseline(version, config, opts = {}) {
3166
+ const biome = opts.formatWith ? await appBiome(opts.formatWith) : void 0;
3167
+ const cacheFile = join(CACHE_DIR, `${baselineCacheKey(version, config, biome)}.json`);
3168
+ if (existsSync(cacheFile)) return JSON.parse(await readFile(cacheFile, "utf8"));
3169
+ const dir = await renderRelease(version, config);
3170
+ if (biome && opts.formatWith) {
3171
+ await cp(join(opts.formatWith, "biome.json"), join(dir, "biome.json"));
3172
+ const bin = join(opts.formatWith, "node_modules", ".bin", "biome");
3173
+ const res = await x(bin, [
3174
+ "check",
3175
+ "--write",
3176
+ "."
3177
+ ], {
3178
+ throwOnError: false,
3179
+ nodeOptions: { cwd: dir }
3180
+ });
3181
+ 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)}`);
3182
+ }
3183
+ const hashes = await hashTrackedFiles(dir);
3184
+ mkdirSync(CACHE_DIR, { recursive: true });
3185
+ await writeFile(cacheFile, JSON.stringify(hashes));
3186
+ return hashes;
3187
+ }
3188
+ function createBaseline(project, deps) {
3189
+ const resolved = {
3190
+ releasedVersions,
3191
+ releaseBaseline: (version, config) => releaseBaseline(version, config, { formatWith: project.root }),
3192
+ cliVersion: cliVersion(),
3193
+ ...deps
3194
+ };
3195
+ const recorded = project.config.generated?.files ?? {};
3196
+ let releases;
3197
+ async function loadReleases() {
3198
+ const from = project.config.version;
3199
+ const versions = (await resolved.releasedVersions()).filter((v) => compareVersions(v, from) >= 0 && compareVersions(v, resolved.cliVersion) <= 0);
3200
+ const out = [];
3201
+ for (const v of versions) out.push(await resolved.releaseBaseline(v, project.config).catch(() => ({})));
3202
+ return out;
3203
+ }
3204
+ return { async isUnchanged(path, content) {
3205
+ const hash = hashContent(content);
3206
+ if (recorded[path] === hash) return true;
3207
+ releases ??= loadReleases();
3208
+ return (await releases).some((files) => files[path] === hash);
3209
+ } };
3210
+ }
3211
+ //#endregion
3212
+ //#region src/upgrade/codemods/core-package.ts
3213
+ const CORE = "@tatara/core";
3214
+ /** Files @tatara/core replaces, with the import specifier apps used for each. */
3215
+ const MOVED = {
3216
+ "src/core/commands/define.ts": "#core/commands/define",
3217
+ "src/core/commands/runner.ts": "#core/commands/runner",
3218
+ "src/core/process/lifecycle.ts": "#core/process/lifecycle",
3219
+ "src/core/http/health.ts": "#core/http/health"
3220
+ };
3221
+ const ENV = "src/core/config/env.ts";
3222
+ const LOGGER = "src/core/logging/logger.ts";
3223
+ const CLI_ENTRY = "src/apps/cli/index.ts";
3224
+ const SIGNAL_FILES = [
3225
+ "src/core/http/server.ts",
3226
+ "src/apps/worker/index.ts",
3227
+ "src/apps/cron/index.ts"
3228
+ ];
3229
+ const LOGGER_IMPORT = "import { logger } from \"#core/logging/logger\";";
3230
+ 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/;
3231
+ async function exists(ctx, path) {
3232
+ return ctx.read(path).catch(() => void 0);
3233
+ }
3234
+ /** Adds `line` after the last import, unless the file already has it. */
3235
+ function withImport(text, line) {
3236
+ if (text.includes(line)) return text;
3237
+ const last = [...text.matchAll(/^import .*;$/gm)].at(-1);
3238
+ if (!last || last.index === void 0) return `${line}\n${text}`;
3239
+ const end = last.index + last[0].length;
3240
+ return `${text.slice(0, end)}\n${line}${text.slice(end)}`;
3241
+ }
3242
+ async function rewriteEnv(ctx) {
3243
+ const text = await exists(ctx, ENV);
3244
+ if (!text || !OLD_HELPER.test(text)) return;
3245
+ 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\";");
3246
+ await ctx.write(ENV, next);
3247
+ }
3248
+ /** Only call sites of functions that now come from @tatara/core take the logger argument. */
3249
+ async function passLogger(ctx, gone) {
3250
+ const runnerGone = gone.includes(MOVED["src/core/commands/runner.ts"]);
3251
+ const lifecycleGone = gone.includes(MOVED["src/core/process/lifecycle.ts"]);
3252
+ const cli = await exists(ctx, CLI_ENTRY);
3253
+ if (cli) {
3254
+ let next = cli;
3255
+ if (runnerGone) next = next.replace("runCommand(commands, process.argv.slice(2));", "runCommand(commands, process.argv.slice(2), logger);");
3256
+ if (lifecycleGone) next = next.replace("await shutdown(code);", "await shutdown(code, logger);");
3257
+ if (next !== cli) await ctx.write(CLI_ENTRY, withImport(next, LOGGER_IMPORT));
3258
+ }
3259
+ if (!lifecycleGone) return;
3260
+ for (const path of SIGNAL_FILES) {
3261
+ const text = await exists(ctx, path);
3262
+ if (text?.includes("handleSignals();")) await ctx.write(path, withImport(text.replace("handleSignals();", "handleSignals(logger);"), LOGGER_IMPORT));
3263
+ }
3264
+ }
3265
+ const WORKSPACE = "pnpm-workspace.yaml";
3266
+ const EXCLUDE_ENTRY = " - \"@tatara/*\"";
3267
+ /** pnpm refuses packages younger than its minimum release age; tatara's own are exempt. */
3268
+ async function allowFreshReleases(ctx) {
3269
+ if (ctx.project.config.packageManager !== "pnpm") return;
3270
+ const text = await exists(ctx, WORKSPACE);
3271
+ if (text === void 0 || /["']@tatara\/\*["']/.test(text)) return;
3272
+ const lines = text.replace(/\n$/, "").split("\n");
3273
+ const key = lines.findIndex((line) => line.trim() === "minimumReleaseAgeExclude:");
3274
+ if (key === -1) lines.push("minimumReleaseAgeExclude:", EXCLUDE_ENTRY);
3275
+ else lines.splice(key + 1, 0, EXCLUDE_ENTRY);
3276
+ await ctx.write(WORKSPACE, `${lines.join("\n")}\n`);
3277
+ }
3278
+ async function useLoggerOptions(ctx) {
3279
+ const text = await exists(ctx, LOGGER);
3280
+ if (!text || text.includes("loggerOptions")) return;
3281
+ if (!await ctx.baseline.isUnchanged(LOGGER, text)) {
3282
+ 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.`);
3283
+ return;
3284
+ }
3285
+ 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\`.`);
3286
+ await ctx.write(LOGGER, `import { loggerOptions } from "@tatara/core";
3287
+ import { pino } from "pino";
3288
+ import { env } from "#core/config/env";
3289
+
3290
+ export const logger = pino(
3291
+ loggerOptions({ env: env.NODE_ENV, level: env.LOG_LEVEL, service: "${ctx.project.config.name}" }),
3292
+ );
3293
+
3294
+ export type Logger = typeof logger;
3295
+ `);
3296
+ }
3297
+ //#endregion
3298
+ //#region src/upgrade/codemods.ts
3299
+ /** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
3300
+ const CODEMODS = [{
3301
+ version: "0.2.0",
3302
+ id: "core-package",
3303
+ description: "Use @tatara/core for commands, flags, lifecycle, readiness, logger options and localDefault",
3304
+ async run(ctx) {
3305
+ await ctx.addDependency(CORE, versionOf(CORE));
3306
+ const gone = [];
3307
+ for (const [path, specifier] of Object.entries(MOVED)) 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.`);
3308
+ else gone.push(specifier);
3309
+ for (const specifier of gone) await ctx.rewriteImports(specifier, CORE);
3310
+ await rewriteEnv(ctx);
3311
+ await passLogger(ctx, gone);
3312
+ await allowFreshReleases(ctx);
3313
+ await useLoggerOptions(ctx);
3314
+ }
3315
+ }];
3316
+ //#endregion
3317
+ //#region src/upgrade/context.ts
3318
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3319
+ function createContext(project, baseline, opts = {}) {
3320
+ const report = {
3321
+ ran: [],
3322
+ written: [],
3323
+ removed: [],
3324
+ kept: [],
3325
+ notes: []
3326
+ };
3327
+ const abs = (path) => join(project.root, path);
3328
+ const write = async (path, text) => {
3329
+ if (!report.written.includes(path)) report.written.push(path);
3330
+ if (opts.dryRun) return;
3331
+ await mkdir(dirname(abs(path)), { recursive: true });
3332
+ await writeFile(abs(path), text);
3333
+ };
3334
+ return {
3335
+ project,
3336
+ baseline,
3337
+ report,
3338
+ read: (path) => readFile(abs(path), "utf8"),
3339
+ write,
3340
+ async removeIfUnchanged(path) {
3341
+ if (!existsSync(abs(path))) return "missing";
3342
+ const content = await readFile(abs(path), "utf8");
3343
+ if (await baseline.isUnchanged(path, content)) {
3344
+ if (!opts.dryRun) await rm(abs(path));
3345
+ report.removed.push(path);
3346
+ return "removed";
3347
+ }
3348
+ const diff = content.split("\n").map((line) => `+${line}`).join("\n");
3349
+ report.kept.push({
3350
+ path,
3351
+ diff
3352
+ });
3353
+ return "kept";
3354
+ },
3355
+ async rewriteImports(from, to) {
3356
+ const pattern = new RegExp(`(["'])${escapeRegExp(from)}(?=(?:/[^"']*)?\\1)`, "g");
3357
+ let changed = 0;
3358
+ for (const path of await glob(["src/**/*.ts", "tests/**/*.ts"], {
3359
+ cwd: project.root,
3360
+ ignore: ["src/generated/**"]
3361
+ })) {
3362
+ const text = await readFile(abs(path), "utf8");
3363
+ const next = text.replace(pattern, `$1${to}`);
3364
+ if (next !== text) {
3365
+ await write(path, next);
3366
+ changed++;
3367
+ }
3368
+ }
3369
+ return changed;
3370
+ },
3371
+ async addDependency(name, range, { dev = false } = {}) {
3372
+ const pkg = JSON.parse(await readFile(abs("package.json"), "utf8"));
3373
+ const key = dev ? "devDependencies" : "dependencies";
3374
+ pkg[key] = {
3375
+ ...pkg[key],
3376
+ [name]: range
3377
+ };
3378
+ await write("package.json", `${JSON.stringify(pkg, null, " ")}\n`);
3379
+ }
3380
+ };
3381
+ }
3382
+ //#endregion
3383
+ //#region src/commands/upgrade.ts
3384
+ async function biomeFormat(file, project) {
3385
+ const biome = join(project.root, "node_modules", ".bin", "biome");
3386
+ if (!existsSync(biome)) return;
3387
+ await x(biome, [
3388
+ "check",
3389
+ "--write",
3390
+ file
3391
+ ], {
3392
+ throwOnError: false,
3393
+ nodeOptions: { cwd: project.root }
3394
+ });
3395
+ }
3396
+ /**
3397
+ * The recorded hashes stay what tatara generated: files the app changed keep their old (generated)
3398
+ * hash, and only files this upgrade wrote or removed are updated.
3399
+ */
3400
+ async function nextRecord(project, report) {
3401
+ const record = { ...project.config.generated?.files ?? {} };
3402
+ for (const path of report.removed) delete record[path];
3403
+ const current = await hashTrackedFiles(project.root);
3404
+ for (const path of report.written) {
3405
+ const hash = current[path];
3406
+ if (hash) record[path] = hash;
3407
+ }
3408
+ return record;
3409
+ }
3410
+ /**
3411
+ * Runs the codemods between the app's tatara version and this CLI's, then syncs and records the
3412
+ * new version and file hashes. A failing codemod stops the upgrade before the version is recorded.
3413
+ */
3414
+ async function upgrade(project, opts = {}) {
3415
+ const target = opts.cliVersion ?? cliVersion();
3416
+ const from = project.config.version;
3417
+ const ctx = createContext(project, opts.baseline ?? createBaseline(project), { dryRun: opts.dryRun });
3418
+ 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));
3419
+ for (const mod of due) {
3420
+ try {
3421
+ await mod.run(ctx);
3422
+ } catch (error) {
3423
+ throw new Error(`codemod ${mod.id} (${mod.version}) failed: ${error.message}`, { cause: error });
3424
+ }
3425
+ ctx.report.ran.push(mod.id);
3426
+ }
3427
+ if (opts.dryRun) return ctx.report;
3428
+ const format = opts.formatFile ?? biomeFormat;
3429
+ for (const path of ctx.report.written) await format(path, project);
3430
+ await (opts.runSync ?? sync)(project);
3431
+ const path = join(project.root, "tatara.json");
3432
+ const config = JSON.parse(await readFile(path, "utf8"));
3433
+ const next = {
3434
+ ...config,
3435
+ version: target,
3436
+ generated: {
3437
+ cli: target,
3438
+ files: await nextRecord(project, ctx.report)
3439
+ }
3440
+ };
3441
+ if (JSON.stringify(next) !== JSON.stringify(config)) {
3442
+ await writeFile(path, `${JSON.stringify(next, null, " ")}\n`);
3443
+ await format("tatara.json", project);
3444
+ }
3445
+ return ctx.report;
3446
+ }
3447
+ function printUpgrade(report, from, to) {
3448
+ if (report.ran.length === 0 && report.removed.length === 0 && report.kept.length === 0) {
3449
+ console.log(pc.green(`nothing to upgrade (${from} → ${to})`));
3450
+ return;
3451
+ }
3452
+ console.log(pc.bold(`tatara upgrade ${from} → ${to}`));
3453
+ for (const id of report.ran) console.log(` ran ${id}`);
3454
+ for (const path of report.removed) console.log(pc.dim(` removed ${path} (now provided by a package)`));
3455
+ for (const { path } of report.kept) console.log(pc.yellow(` kept ${path}: changed in this app; move the change into options or eject`));
3456
+ for (const note of report.notes) console.log(` ${note}`);
3457
+ }
3458
+ //#endregion
3019
3459
  //#region src/bin.ts
3020
3460
  const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
3021
3461
  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 +3527,12 @@ program.command("routes").description("List every route and what protects it").o
3087
3527
  if (flags.json) console.log(JSON.stringify(routes, null, 2));
3088
3528
  else printRoutes(routes);
3089
3529
  });
3530
+ 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) => {
3531
+ const project = loadProject();
3532
+ const from = project.config.version;
3533
+ printUpgrade(await upgrade(project, { dryRun: flags.dryRun }), from, cliVersion());
3534
+ if (!flags.dryRun && flags.verify !== false && !await verify(loadProject())) process.exitCode = 1;
3535
+ });
3090
3536
  program.command("doctor").description("Check the toolchain, services, env and layout").option("--json", "machine-readable output").action(async (flags) => {
3091
3537
  const checks = await doctor(loadProject());
3092
3538
  if (flags.json) console.log(JSON.stringify(checks, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,15 @@
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/mohammed-bageri/tatara-cli.git",
34
+ "directory": "packages/cli"
35
+ },
36
+ "homepage": "https://github.com/mohammed-bageri/tatara-cli#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/mohammed-bageri/tatara-cli/issues"
39
+ },
31
40
  "scripts": {
32
41
  "build": "tsdown",
33
42
  "typecheck": "tsc --noEmit"
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { issuer } from "#core/auth/keycloak";
3
- import { defineCommand } from "#core/commands/define";
3
+ import { defineCommand } from "@tatara/core";
4
4
  import { env } from "#core/config/env";
5
5
 
6
6
  export default defineCommand({
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "#core/authz/constants";
3
3
  import { store } from "#core/authz/db/store";
4
4
  import { assignRole } from "#core/authz/services/members";
5
- import { defineCommand } from "#core/commands/define";
5
+ import { defineCommand } from "@tatara/core";
6
6
 
7
7
  export default defineCommand({
8
8
  name: "authz:grant-role",
@@ -1,4 +1,4 @@
1
- import { defineCommand } from "#core/commands/define";
1
+ import { defineCommand } from "@tatara/core";
2
2
  import { syncSystemRoles } from "#core/authz/services/sync";
3
3
 
4
4
  export default defineCommand({
@@ -40,6 +40,7 @@ __PM_EXEC__ tatara make resource <name> --module <module> # CRUD slice
40
40
  __PM_EXEC__ tatara make route|service|command <name> --module <module>
41
41
  __PM_EXEC__ tatara run --list # app commands
42
42
  __PM_EXEC__ tatara routes # every route and what protects it
43
+ __PM_EXEC__ tatara upgrade # after bumping @tatara/cli: run its codemods, sync, verify
43
44
  ```
44
45
 
45
46
  ## HTTP
@@ -1,6 +1,6 @@
1
1
  # __APP_NAME__
2
2
 
3
- A Hono API created with [tatara](https://github.com/mohammed-bageri/tatara).
3
+ A Hono API created with [tatara](https://github.com/mohammed-bageri/tatara-cli).
4
4
 
5
5
  ## Getting started
6
6
 
@@ -1,6 +1,6 @@
1
- import { runCommand } from "#core/commands/runner";
2
- import { shutdown } from "#core/process/lifecycle";
1
+ import { runCommand, shutdown } from "@tatara/core";
2
+ import { logger } from "#core/logging/logger";
3
3
  import { commands } from "#generated/commands";
4
4
 
5
- const code = await runCommand(commands, process.argv.slice(2));
6
- await shutdown(code);
5
+ const code = await runCommand(commands, process.argv.slice(2), logger);
6
+ await shutdown(code, logger);
@@ -5,7 +5,7 @@ import { cors } from "hono/cors";
5
5
  import { secureHeaders } from "hono/secure-headers";
6
6
  import { env } from "#core/config/env";
7
7
  import type { AppEnv } from "#core/http/context";
8
- import { runReadinessChecks } from "#core/http/health";
8
+ import { runReadinessChecks } from "@tatara/core";
9
9
  import { errorHandler, notFoundHandler } from "#core/http/middleware/error-handler";
10
10
  import { requestContext } from "#core/http/middleware/request-context";
11
11
  import { validationHook } from "#core/http/middleware/validation";
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
2
2
  import type { OpenAPIHono } from "@hono/zod-openapi";
3
3
  import type { AppEnv } from "#core/http/context";
4
4
  import { logger } from "#core/logging/logger";
5
- import { handleSignals, onShutdown } from "#core/process/lifecycle";
5
+ import { handleSignals, onShutdown } from "@tatara/core";
6
6
 
7
7
  /** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
8
8
  export function startServer(app: OpenAPIHono<AppEnv>, port: number, name: string) {
@@ -10,6 +10,6 @@ export function startServer(app: OpenAPIHono<AppEnv>, port: number, name: string
10
10
  logger.info({ port: info.port, app: name }, `${name} listening on http://localhost:${info.port}`);
11
11
  });
12
12
  onShutdown(`${name}-http`, () => new Promise<void>((resolve) => server.close(() => resolve())));
13
- handleSignals();
13
+ handleSignals(logger);
14
14
  return server;
15
15
  }
@@ -1,17 +1,9 @@
1
+ import { loggerOptions } from "@tatara/core";
1
2
  import { pino } from "pino";
2
3
  import { env } from "#core/config/env";
3
4
 
4
- export const logger = pino({
5
- level: env.LOG_LEVEL,
6
- base: { service: "__APP_NAME__" },
7
- ...(env.NODE_ENV === "development"
8
- ? {
9
- transport: {
10
- target: "pino-pretty",
11
- options: { colorize: true, ignore: "pid,hostname,service" },
12
- },
13
- }
14
- : {}),
15
- });
5
+ export const logger = pino(
6
+ loggerOptions({ env: env.NODE_ENV, level: env.LOG_LEVEL, service: "__APP_NAME__" }),
7
+ );
16
8
 
17
9
  export type Logger = typeof logger;
@@ -1,4 +1,4 @@
1
- import { defineCommand } from "#core/commands/define";
1
+ import { defineCommand } from "@tatara/core";
2
2
  import { env } from "#core/config/env";
3
3
  import { migrateDatabase } from "#core/db/migrate";
4
4
 
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineCommand, flag } from "#core/commands/define";
2
+ import { defineCommand, flag } from "@tatara/core";
3
3
  import { env } from "#core/config/env";
4
4
  import { dropAllTables } from "#core/db/admin";
5
5
  import { migrateDatabase } from "#core/db/migrate";
@@ -1,4 +1,4 @@
1
- import { defineCommand } from "#core/commands/define";
1
+ import { defineCommand } from "@tatara/core";
2
2
  import { seeds } from "#generated/seeds";
3
3
 
4
4
  export default defineCommand({
@@ -1,5 +1,5 @@
1
1
  import { expect, it } from "vitest";
2
- import { parseFlags } from "#core/commands/runner";
2
+ import { parseFlags } from "@tatara/core";
3
3
  import reset from "#core/db/commands/reset";
4
4
 
5
5
  it("does not treat --force false as forcing db:reset", () => {
@@ -1,8 +1,8 @@
1
1
  import { drizzle } from "drizzle-orm/mysql2";
2
2
  import mysql from "mysql2/promise";
3
3
  import { env } from "#core/config/env";
4
- import { registerReadinessCheck } from "#core/http/health";
5
- import { onShutdown } from "#core/process/lifecycle";
4
+ import { registerReadinessCheck } from "@tatara/core";
5
+ import { onShutdown } from "@tatara/core";
6
6
  import * as schema from "#generated/schema";
7
7
 
8
8
  export const pool = mysql.createPool({ uri: env.DATABASE_URL, connectionLimit: env.DATABASE_POOL_MAX });
@@ -1,8 +1,8 @@
1
1
  import { drizzle } from "drizzle-orm/node-postgres";
2
2
  import pg from "pg";
3
3
  import { env } from "#core/config/env";
4
- import { registerReadinessCheck } from "#core/http/health";
5
- import { onShutdown } from "#core/process/lifecycle";
4
+ import { registerReadinessCheck } from "@tatara/core";
5
+ import { onShutdown } from "@tatara/core";
6
6
  import * as schema from "#generated/schema";
7
7
 
8
8
  export const pool = new pg.Pool({ connectionString: env.DATABASE_URL, max: env.DATABASE_POOL_MAX });
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineCommand } from "#core/commands/define";
2
+ import { defineCommand } from "@tatara/core";
3
3
  import { sendEmail } from "#core/email/send";
4
4
 
5
5
  export default defineCommand({
@@ -1,7 +1,7 @@
1
1
  import { Cron } from "croner";
2
2
  import { withLock } from "#core/jobs/lock";
3
3
  import { logger } from "#core/logging/logger";
4
- import { handleSignals, onShutdown } from "#core/process/lifecycle";
4
+ import { handleSignals, onShutdown } from "@tatara/core";
5
5
  import { schedules } from "#generated/schedules";
6
6
 
7
7
  const jobs = schedules.map((schedule) => {
@@ -21,5 +21,5 @@ const jobs = schedules.map((schedule) => {
21
21
  onShutdown("cron", () => {
22
22
  for (const job of jobs) job.stop();
23
23
  });
24
- handleSignals();
24
+ handleSignals(logger);
25
25
  logger.info({ schedules: schedules.map((s) => `${s.name} (${s.cron})`) }, "cron started");
@@ -4,7 +4,7 @@ import { processJob } from "#core/jobs/process";
4
4
  import { QUEUE_NAME } from "#core/jobs/queue";
5
5
  import { redis } from "#core/jobs/redis";
6
6
  import { logger } from "#core/logging/logger";
7
- import { handleSignals, onShutdown } from "#core/process/lifecycle";
7
+ import { handleSignals, onShutdown } from "@tatara/core";
8
8
  import { consumers } from "#generated/consumers";
9
9
 
10
10
  const worker = new Worker(
@@ -20,5 +20,5 @@ const worker = new Worker(
20
20
 
21
21
  worker.on("failed", (job, err) => logger.error({ err, jobId: job?.id, topic: job?.name }, "job failed"));
22
22
  onShutdown("worker", () => worker.close());
23
- handleSignals();
23
+ handleSignals(logger);
24
24
  logger.info({ topics: consumers.map((c) => c.topic) }, "worker started");
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineCommand } from "#core/commands/define";
2
+ import { defineCommand } from "@tatara/core";
3
3
  import { enqueue } from "#core/jobs/queue";
4
4
 
5
5
  export default defineCommand({
@@ -1,7 +1,7 @@
1
1
  import { type JobsOptions, Queue } from "bullmq";
2
2
  import { env } from "#core/config/env";
3
3
  import { redis } from "#core/jobs/redis";
4
- import { onShutdown } from "#core/process/lifecycle";
4
+ import { onShutdown } from "@tatara/core";
5
5
 
6
6
  export const QUEUE_NAME = "__APP_NAME__-jobs";
7
7
 
@@ -1,6 +1,6 @@
1
1
  import { Redis } from "ioredis";
2
2
  import { env } from "#core/config/env";
3
- import { onShutdown } from "#core/process/lifecycle";
3
+ import { onShutdown } from "@tatara/core";
4
4
 
5
5
  let client: Redis | undefined;
6
6
 
@@ -1,8 +1,8 @@
1
1
  import { PrismaMariaDb } from "@prisma/adapter-mariadb";
2
2
  import { env } from "#core/config/env";
3
3
  import { connectionConfig } from "#core/db/admin";
4
- import { registerReadinessCheck } from "#core/http/health";
5
- import { onShutdown } from "#core/process/lifecycle";
4
+ import { registerReadinessCheck } from "@tatara/core";
5
+ import { onShutdown } from "@tatara/core";
6
6
  import { type Prisma, PrismaClient } from "#generated/prisma/client";
7
7
 
8
8
  export const db = new PrismaClient({
@@ -1,7 +1,7 @@
1
1
  import { PrismaPg } from "@prisma/adapter-pg";
2
2
  import { env } from "#core/config/env";
3
- import { registerReadinessCheck } from "#core/http/health";
4
- import { onShutdown } from "#core/process/lifecycle";
3
+ import { registerReadinessCheck } from "@tatara/core";
4
+ import { onShutdown } from "@tatara/core";
5
5
  import { type Prisma, PrismaClient } from "#generated/prisma/client";
6
6
 
7
7
  export const db = new PrismaClient({
@@ -1,4 +1,4 @@
1
- import { defineCommand } from "#core/commands/define";
1
+ import { defineCommand } from "@tatara/core";
2
2
  import { env } from "#core/config/env";
3
3
  import { storage } from "#core/storage/index";
4
4
  import { ensureBucket } from "#core/storage/s3";
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineCommand, flag } from "#core/commands/define";
2
+ import { defineCommand, flag } from "@tatara/core";
3
3
 
4
4
  export default defineCommand({
5
5
  name: "__moduleKebab__:__nameKebab__",
@@ -30,5 +30,6 @@
30
30
  "@aws-sdk/client-s3": "3.1152.0",
31
31
  "@aws-sdk/s3-request-presigner": "3.1152.0",
32
32
  "nodemailer": "10.1.1",
33
- "@types/nodemailer": "8.0.2"
33
+ "@types/nodemailer": "8.0.2",
34
+ "@tatara/core": "0.1.0"
34
35
  }
@@ -1,33 +0,0 @@
1
- import { z } from "zod";
2
- import type { Logger } from "#core/logging/logger";
3
-
4
- export interface CommandContext {
5
- logger: Logger;
6
- }
7
-
8
- export interface CommandDefinition<S extends z.ZodType = z.ZodType> {
9
- /** `module:action`, e.g. "billing:recalculate" */
10
- name: string;
11
- description: string;
12
- /** flags parsed from `--key value`; use z.coerce for numbers and `flag()` for booleans */
13
- args?: S;
14
- run(args: z.infer<S>, ctx: CommandContext): Promise<void> | void;
15
- }
16
-
17
-
18
- /** Default export of every `commands/*.ts`; discovered by `tatara sync`. */
19
- export function defineCommand<S extends z.ZodType = typeof noArgs>(
20
- definition: CommandDefinition<S>,
21
- ): CommandDefinition<S> {
22
- return definition;
23
- }
24
-
25
- export const noArgs = z.object({});
26
-
27
- /**
28
- * A boolean flag: bare `--force` is true, `--force false|0|no|off` is false.
29
- * (`z.coerce.boolean()` would read the string "false" as true.)
30
- */
31
- export function flag() {
32
- return z.union([z.boolean(), z.stringbool()]).default(false);
33
- }
@@ -1,54 +0,0 @@
1
- import type { CommandDefinition } from "#core/commands/define";
2
- import { logger } from "#core/logging/logger";
3
-
4
- /** Parses `--key value`, `--key=value` and bare `--flag` (true) into an object. */
5
- export function parseFlags(argv: string[]): Record<string, string | boolean> {
6
- const flags: Record<string, string | boolean> = {};
7
- for (let i = 0; i < argv.length; i++) {
8
- const arg = argv[i] ?? "";
9
- if (!arg.startsWith("--")) continue;
10
- const [rawKey, inline] = arg.slice(2).split("=", 2);
11
- const key = (rawKey ?? "").replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
12
- if (inline !== undefined) {
13
- flags[key] = inline;
14
- } else if (argv[i + 1] !== undefined && !argv[i + 1]?.startsWith("--")) {
15
- flags[key] = argv[i + 1] ?? "";
16
- i++;
17
- } else {
18
- flags[key] = true;
19
- }
20
- }
21
- return flags;
22
- }
23
-
24
- export async function runCommand(commands: CommandDefinition[], argv: string[]): Promise<number> {
25
- const [name, ...rest] = argv;
26
- if (!name || name === "--list" || name === "list" || name === "help") {
27
- const width = Math.max(0, ...commands.map((c) => c.name.length));
28
- console.log("Commands:");
29
- for (const c of [...commands].sort((a, b) => a.name.localeCompare(b.name))) {
30
- console.log(` ${c.name.padEnd(width)} ${c.description}`);
31
- }
32
- return 0;
33
- }
34
- const command = commands.find((c) => c.name === name);
35
- if (!command) {
36
- console.error(`Unknown command "${name}". Run with --list to see available commands.`);
37
- return 1;
38
- }
39
- const parsed = (command.args ?? null)?.safeParse(parseFlags(rest));
40
- if (parsed && !parsed.success) {
41
- console.error(`Invalid arguments for ${name}:`);
42
- for (const issue of parsed.error.issues) {
43
- console.error(` --${issue.path.join(".")}: ${issue.message}`);
44
- }
45
- return 1;
46
- }
47
- try {
48
- await command.run(parsed ? parsed.data : {}, { logger: logger.child({ command: name }) });
49
- return 0;
50
- } catch (error) {
51
- logger.error({ err: error, command: name }, "command failed");
52
- return 1;
53
- }
54
- }
@@ -1,16 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { z } from "zod";
3
- import { flag } from "#core/commands/define";
4
- import { parseFlags } from "#core/commands/runner";
5
-
6
- const args = z.object({ force: flag() });
7
- const parse = (argv: string[]) => args.parse(parseFlags(argv)).force;
8
-
9
- describe("flag()", () => {
10
- it("is true for a bare flag", () => expect(parse(["--force"])).toBe(true));
11
- it("reads --flag false as false", () => expect(parse(["--force", "false"])).toBe(false));
12
- it("reads --flag=0 as false", () => expect(parse(["--force=0"])).toBe(false));
13
- it("reads --flag true as true", () => expect(parse(["--force", "true"])).toBe(true));
14
- it("defaults to false", () => expect(parse([])).toBe(false));
15
- it("rejects anything else", () => expect(() => parse(["--force", "maybe"])).toThrow());
16
- });
@@ -1,25 +0,0 @@
1
- type Check = () => Promise<void>;
2
-
3
- const checks = new Map<string, Check>();
4
-
5
- /** Registers a dependency checked by GET /ready (database, redis, …). */
6
- export function registerReadinessCheck(name: string, check: Check): void {
7
- checks.set(name, check);
8
- }
9
-
10
- export async function runReadinessChecks() {
11
- const results = await Promise.all(
12
- [...checks].map(async ([name, check]) => {
13
- try {
14
- await check();
15
- return [name, "ok"] as const;
16
- } catch {
17
- return [name, "failed"] as const;
18
- }
19
- }),
20
- );
21
- return {
22
- ready: results.every(([, status]) => status === "ok"),
23
- checks: Object.fromEntries(results),
24
- };
25
- }
@@ -1,34 +0,0 @@
1
- import { logger } from "#core/logging/logger";
2
-
3
- type Hook = () => Promise<void> | void;
4
-
5
- const hooks: { name: string; run: Hook }[] = [];
6
- let shuttingDown = false;
7
-
8
- /** Registers cleanup that runs on SIGTERM/SIGINT or `shutdown()`, last registered first. */
9
- export function onShutdown(name: string, run: Hook): void {
10
- hooks.push({ name, run });
11
- }
12
-
13
- export async function shutdown(code = 0): Promise<never> {
14
- if (!shuttingDown) {
15
- shuttingDown = true;
16
- for (const hook of [...hooks].reverse()) {
17
- try {
18
- await hook.run();
19
- } catch (error) {
20
- logger.error({ err: error, hook: hook.name }, "shutdown hook failed");
21
- }
22
- }
23
- }
24
- process.exit(code);
25
- }
26
-
27
- export function handleSignals(): void {
28
- for (const signal of ["SIGTERM", "SIGINT"] as const) {
29
- process.once(signal, () => {
30
- logger.info({ signal }, "shutting down");
31
- void shutdown(0);
32
- });
33
- }
34
- }