@tatara/cli 0.8.0 → 0.9.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.
package/dist/bin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
2
+ import { Command, Option } from "commander";
3
3
  import pc from "picocolors";
4
4
  import { spawn } from "node:child_process";
5
5
  import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
@@ -239,6 +239,55 @@ ${imports.join("\n")}${imports.length ? "\n" : ""}
239
239
  export const ${options.exportName}: ${options.typeName}[] = [${names.join(", ")}];
240
240
  `;
241
241
  }
242
+ /** What the app's installed (or ejected) @tatara/* packages contribute, by package name. */
243
+ async function contributions(project, kind) {
244
+ const pkg = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
245
+ const ejected = ejectedPackages(project.config);
246
+ const names = [...Object.keys({
247
+ ...pkg.dependencies,
248
+ ...pkg.devDependencies
249
+ }).filter((n) => n.startsWith("@tatara/")).map((n) => n.slice(8)), ...ejected];
250
+ const out = [];
251
+ for (const name of [...new Set(names)].sort()) {
252
+ const file = ejected.includes(name) ? join(project.root, "src/ejected", name, "tatara-package.json") : join(project.root, "node_modules/@tatara", name, "package.json");
253
+ if (!existsSync(file)) continue;
254
+ const json = JSON.parse(await readFile(file, "utf8"));
255
+ out.push(...(ejected.includes(name) ? json[kind] : json.tatara?.[kind]) ?? []);
256
+ }
257
+ return out;
258
+ }
259
+ /**
260
+ * Like listModule, plus the packages' factories: `own` are the app's files, and an own entry wins
261
+ * over a package's entry with the same name.
262
+ */
263
+ function listWithPackages(files, from, options) {
264
+ if (from.length === 0) return listModule(files, options);
265
+ const imports = /* @__PURE__ */ new Map();
266
+ const bind = (name, spec) => {
267
+ const existing = imports.get(name);
268
+ if (existing && existing !== spec) throw new Error(`tatara packages need "${name}" from both ${existing} and ${spec}`);
269
+ imports.set(name, spec);
270
+ };
271
+ for (const c of from) {
272
+ bind(c.factory, c.import);
273
+ for (const [arg, spec] of Object.entries(c.args)) bind(arg, spec);
274
+ }
275
+ const named = [...imports].map(([name, spec]) => `import { ${name} } from "${spec}";`);
276
+ const own = files.map((f) => `import ${identifier(f, options.prefix)} from "${specifier(f)}";`);
277
+ const calls = from.map((c) => `...${c.factory}({ ${Object.keys(c.args).join(", ")} })`);
278
+ const t = options.typeName;
279
+ return `${HEADER}${options.typeImport}
280
+ ${[...named, ...own].join("\n")}
281
+
282
+ const own: ${t}[] = [${files.map((f) => identifier(f, options.prefix)).join(", ")}];
283
+ // from the installed @tatara/* packages; the app's own entry of the same name wins
284
+ const fromPackages: ${t}[] = [${calls.join(", ")}];
285
+ export const ${options.exportName}: ${t}[] = [
286
+ ...own,
287
+ ...fromPackages.filter((c) => !own.some((o) => o.name === c.name)),
288
+ ];
289
+ `;
290
+ }
242
291
  async function writeIfChanged(path, content, written) {
243
292
  if ((existsSync(path) ? await readFile(path, "utf8") : void 0) !== content) {
244
293
  await writeFile(path, content);
@@ -261,7 +310,7 @@ async function sync(project = loadProject()) {
261
310
  exportName: "routes"
262
311
  }), written);
263
312
  const commands = await find(root, dirs.map((d) => `${d}/commands/*.ts`));
264
- await write(join(out, "commands.ts"), listModule(commands, {
313
+ await write(join(out, "commands.ts"), listWithPackages(commands, await contributions(project, "commands"), {
265
314
  prefix: "command",
266
315
  typeImport: "import type { CommandDefinition } from \"@tatara/core\";",
267
316
  typeName: "CommandDefinition",
@@ -297,7 +346,7 @@ export type PermissionKey =
297
346
  }
298
347
  if (project.config.database !== "none") {
299
348
  const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
300
- await write(join(out, "seeds.ts"), listModule(seeds, {
349
+ await write(join(out, "seeds.ts"), listWithPackages(seeds, await contributions(project, "seeds"), {
301
350
  prefix: "seed",
302
351
  typeImport: "import type { SeedDefinition } from \"@tatara/db\";",
303
352
  typeName: "SeedDefinition",
@@ -722,6 +771,10 @@ async function eject(project, name, opts = {}) {
722
771
  await cp(join(source, file), join(project.root, to));
723
772
  report.copied.push(to);
724
773
  }
774
+ if (manifest.tatara) {
775
+ const to = `src/ejected/${dep}/tatara-package.json`;
776
+ await writeFile(join(project.root, to), `${JSON.stringify(manifest.tatara, null, " ")}\n`);
777
+ }
725
778
  }
726
779
  const ejected = [...already, ...chain];
727
780
  for (const file of await glob(["src/**/*.ts", "tests/**/*.ts"], { cwd: project.root })) {
@@ -2383,6 +2436,10 @@ function pnpmWorkspace(features) {
2383
2436
  lines.push("# tatara's own packages are installed as soon as they are released", "minimumReleaseAgeExclude:", " - \"@tatara/*\"");
2384
2437
  return `${lines.join("\n")}\n`;
2385
2438
  }
2439
+ /** Tests of tatara's wiring, not of the app; generated apps leave them out. */
2440
+ function isFrameworkTest(path) {
2441
+ return /^src\/core\/.+\/tests\/[^/]+\.test\.ts$/.test(path);
2442
+ }
2386
2443
  /** Folds applicable variants into each feature so later steps see one flat definition. */
2387
2444
  function effectiveFeatures(options) {
2388
2445
  const features = resolveFeatures(options);
@@ -2406,6 +2463,9 @@ async function renderApp(options, generate = {}) {
2406
2463
  const selected = new Set(features.map((f) => f.id));
2407
2464
  const tokenValues = tokens(options);
2408
2465
  const files = await collectTemplateFiles(features, selected);
2466
+ if (!generate.frameworkTests) {
2467
+ for (const path of files.keys()) if (isFrameworkTest(path)) files.delete(path);
2468
+ }
2409
2469
  const env = dedupeEnv(features, tokenValues);
2410
2470
  files.set("src/core/config/env.ts", envModule(env.filter((v) => !v.composeOnly)));
2411
2471
  files.set(".env.example", envFile(env, "example"));
@@ -2998,7 +3058,10 @@ async function newApp(dir, rawFlags) {
2998
3058
  const name = root.split(/[\\/]/).at(-1) ?? "app";
2999
3059
  const options = validateOptions(interactive ? await prompt(name, flags) : fromFlags(name, flags));
3000
3060
  const pm = pmCommands(options.packageManager);
3001
- await writeFiles(root, await renderApp(options, { pmVersion: await pmVersion(options.packageManager) }), { env: devEnvFromExample(options) });
3061
+ await writeFiles(root, await renderApp(options, {
3062
+ pmVersion: await pmVersion(options.packageManager),
3063
+ frameworkTests: flags.frameworkTests
3064
+ }), { env: devEnvFromExample(options) });
3002
3065
  const project = loadProject(root);
3003
3066
  await afterCreate(project, "files");
3004
3067
  if (flags.install !== false) {
@@ -3384,10 +3447,7 @@ async function blockersOf$1(ctx, group, sources) {
3384
3447
  return blockers;
3385
3448
  }
3386
3449
  async function applyGroup(ctx, group) {
3387
- for (const path of group.regenerate) {
3388
- const next = await ctx.renderCurrent(path);
3389
- if (next !== void 0) await ctx.write(path, next);
3390
- }
3450
+ for (const path of group.regenerate) await ctx.regenerate(path);
3391
3451
  for (const path of group.remove) await ctx.removeIfUnchanged(path);
3392
3452
  for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
3393
3453
  }
@@ -3633,8 +3693,7 @@ const authPackage = {
3633
3693
  for (const group of ready) {
3634
3694
  for (const path of group.regenerate) {
3635
3695
  if (await read$3(ctx, path) === void 0) continue;
3636
- const next = await ctx.renderCurrent(path);
3637
- if (next !== void 0) await ctx.write(path, next);
3696
+ await ctx.regenerate(path);
3638
3697
  }
3639
3698
  for (const path of group.remove) await ctx.removeIfUnchanged(path);
3640
3699
  }
@@ -3779,15 +3838,43 @@ const authzPackage = {
3779
3838
  ctx.report.blocked.push("authz-package");
3780
3839
  return;
3781
3840
  }
3782
- for (const path of regenerate) {
3783
- const next = await ctx.renderCurrent(path);
3784
- if (next !== void 0) await ctx.write(path, next);
3785
- }
3841
+ for (const path of regenerate) await ctx.regenerate(path);
3786
3842
  for (const path of removed) await ctx.removeIfUnchanged(path);
3787
3843
  for (const move of MOVES) await ctx.rewriteImports(move.from, move.to);
3788
3844
  }
3789
3845
  };
3790
3846
  //#endregion
3847
+ //#region src/upgrade/codemods/authz-stores.ts
3848
+ const group = {
3849
+ name: "authz store",
3850
+ regenerate: ["src/core/authz/index.ts"],
3851
+ remove: ["src/core/authz/db/store.ts"],
3852
+ rewrites: [{
3853
+ from: "#core/authz/db/store",
3854
+ to: "#core/authz/index",
3855
+ names: []
3856
+ }]
3857
+ };
3858
+ const authzStores = {
3859
+ version: "0.9.0",
3860
+ id: "authz-stores",
3861
+ description: "Use the Prisma or Drizzle authz store from @tatara/authz",
3862
+ async run(ctx) {
3863
+ if (!ctx.project.config.addons.includes("authz")) return;
3864
+ if (ejectedPackages(ctx.project.config).includes("authz")) {
3865
+ ctx.report.notes.push("@tatara/authz is ejected; its store stays in src/core/authz/db/store.ts");
3866
+ return;
3867
+ }
3868
+ const blockers = await blockersOf$1(ctx, group, await sourceFiles(ctx));
3869
+ if (blockers.length > 0) {
3870
+ ctx.report.notes.push(`The authz store was not moved onto @tatara/authz: ${blockers.join("; ")}. Pass prismaAuthzStore(db) or drizzleAuthzStore(db, tables) to createAuthz by hand.`);
3871
+ ctx.report.blocked.push("authz-stores");
3872
+ return;
3873
+ }
3874
+ await applyGroup(ctx, group);
3875
+ }
3876
+ };
3877
+ //#endregion
3791
3878
  //#region src/upgrade/codemods/core-package.ts
3792
3879
  const CORE = "@tatara/core";
3793
3880
  /** Files @tatara/core replaces, with the import specifier apps used for each. */
@@ -3952,6 +4039,20 @@ const dbTesting = {
3952
4039
  }
3953
4040
  };
3954
4041
  //#endregion
4042
+ //#region src/upgrade/codemods/framework-tests.ts
4043
+ const frameworkTests = {
4044
+ version: "0.9.0",
4045
+ id: "framework-tests",
4046
+ description: "Remove tatara's own wiring tests from src/core; the packages and tatara's e2e cover them",
4047
+ async run(ctx) {
4048
+ for (const path of await sourceFiles(ctx)) {
4049
+ if (!isFrameworkTest(path)) continue;
4050
+ if (!await ctx.baseline.isUnchanged(path, await ctx.read(path))) continue;
4051
+ await ctx.removeIfUnchanged(path);
4052
+ }
4053
+ }
4054
+ };
4055
+ //#endregion
3955
4056
  //#region src/upgrade/codemods/http-package.ts
3956
4057
  const HTTP = "@tatara/http";
3957
4058
  /** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
@@ -4014,58 +4115,94 @@ async function outsideImporters(ctx) {
4014
4115
  }
4015
4116
  return out;
4016
4117
  }
4118
+ const httpPackage = {
4119
+ version: "0.3.0",
4120
+ id: "http-package",
4121
+ description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
4122
+ async run(ctx) {
4123
+ await ctx.addDependency(HTTP, versionOf(HTTP));
4124
+ const blockers = [];
4125
+ const replace = [];
4126
+ for (const path of [
4127
+ APP,
4128
+ ROUTES,
4129
+ ...SERVERS
4130
+ ]) {
4131
+ const text = await read(ctx, path);
4132
+ if (text === void 0) continue;
4133
+ if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
4134
+ else blockers.push(`${path} is customised`);
4135
+ }
4136
+ const present = [];
4137
+ for (const entry of MOVED) {
4138
+ const text = await read(ctx, entry.path);
4139
+ if (text === void 0) continue;
4140
+ present.push(entry);
4141
+ if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
4142
+ }
4143
+ for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
4144
+ if (blockers.length > 0) {
4145
+ 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.`);
4146
+ ctx.report.blocked.push("http-package");
4147
+ return;
4148
+ }
4149
+ for (const path of replace) await ctx.regenerate(path);
4150
+ for (const entry of present) await ctx.removeIfUnchanged(entry.path);
4151
+ for (const specifier of [
4152
+ "#core/http/errors",
4153
+ "#core/http/response",
4154
+ "#core/http/middleware/validation"
4155
+ ]) await ctx.rewriteImports(specifier, HTTP);
4156
+ }
4157
+ };
4158
+ //#endregion
4159
+ //#region src/upgrade/codemods/package-commands.ts
4160
+ /** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
4161
+ const COPIES = [
4162
+ "src/core/db/commands/migrate.ts",
4163
+ "src/core/db/commands/reset.ts",
4164
+ "src/core/db/commands/seed.ts",
4165
+ "src/core/authz/commands/grant-role.ts",
4166
+ "src/core/authz/commands/sync-permissions.ts",
4167
+ "src/core/authz/db/seed.ts",
4168
+ "src/core/email/commands/send-test.ts",
4169
+ "src/core/storage/commands/check.ts",
4170
+ "src/core/jobs/commands/enqueue.ts"
4171
+ ];
4172
+ const specifierOf = (path) => `#${path.replace(/^src\//, "").replace(/\.ts$/, "")}`;
4017
4173
  //#endregion
4018
4174
  //#region src/upgrade/codemods.ts
4019
4175
  /** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
4020
4176
  const CODEMODS = [
4021
4177
  corePackage,
4178
+ httpPackage,
4179
+ dbTesting,
4180
+ authPackage,
4181
+ authzPackage,
4182
+ addonPackages,
4183
+ frameworkTests,
4184
+ authzStores,
4022
4185
  {
4023
- version: "0.3.0",
4024
- id: "http-package",
4025
- description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
4186
+ version: "0.9.0",
4187
+ id: "package-commands",
4188
+ description: "Take db, authz, email, storage and jobs commands and seeds from their packages",
4026
4189
  async run(ctx) {
4027
- await ctx.addDependency(HTTP, versionOf(HTTP));
4028
- const blockers = [];
4029
- const replace = [];
4030
- for (const path of [
4031
- APP,
4032
- ROUTES,
4033
- ...SERVERS
4034
- ]) {
4035
- const text = await read(ctx, path);
4036
- if (text === void 0) continue;
4037
- if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
4038
- else blockers.push(`${path} is customised`);
4039
- }
4040
- const present = [];
4041
- for (const entry of MOVED) {
4042
- const text = await read(ctx, entry.path);
4043
- if (text === void 0) continue;
4044
- present.push(entry);
4045
- if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
4046
- }
4047
- for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
4048
- if (blockers.length > 0) {
4049
- 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.`);
4050
- ctx.report.blocked.push("http-package");
4051
- return;
4052
- }
4053
- for (const path of replace) {
4054
- const next = await ctx.renderCurrent(path);
4055
- if (next !== void 0) await ctx.write(path, next);
4190
+ const sources = await sourceFiles(ctx);
4191
+ const texts = /* @__PURE__ */ new Map();
4192
+ for (const path of sources) texts.set(path, await ctx.read(path));
4193
+ const ejected = ejectedPackages(ctx.project.config);
4194
+ for (const path of COPIES) {
4195
+ if (!texts.has(path)) continue;
4196
+ if (ejected.includes(path.split("/")[2] ?? "")) continue;
4197
+ const importers = [...texts].filter(([other, text]) => other !== path && importsOf(text, specifierOf(path)));
4198
+ if (importers.length > 0) {
4199
+ ctx.report.notes.push(`${path} was kept: ${importers.map(([p]) => p).join(", ")} import it.`);
4200
+ continue;
4201
+ }
4202
+ await ctx.removeIfUnchanged(path);
4056
4203
  }
4057
- for (const entry of present) await ctx.removeIfUnchanged(entry.path);
4058
- for (const specifier of [
4059
- "#core/http/errors",
4060
- "#core/http/response",
4061
- "#core/http/middleware/validation"
4062
- ]) await ctx.rewriteImports(specifier, HTTP);
4063
4204
  }
4064
- },
4065
- dbTesting,
4066
- authPackage,
4067
- authzPackage,
4068
- addonPackages
4205
+ }
4069
4206
  ];
4070
4207
  //#endregion
4071
4208
  //#region src/upgrade/context.ts
@@ -4094,7 +4231,7 @@ function createContext(project, baseline, opts = {}) {
4094
4231
  await mkdir(dirname(abs(path)), { recursive: true });
4095
4232
  await writeFile(abs(path), text);
4096
4233
  };
4097
- return {
4234
+ const ctx = {
4098
4235
  project,
4099
4236
  baseline,
4100
4237
  report,
@@ -4144,6 +4281,24 @@ function createContext(project, baseline, opts = {}) {
4144
4281
  });
4145
4282
  return (await rendered).get(path);
4146
4283
  },
4284
+ async regenerate(path) {
4285
+ const next = await ctx.renderCurrent(path);
4286
+ if (next === void 0) {
4287
+ const spec = `#${path.replace(/^src\//, "").replace(/\.ts$/, "")}`;
4288
+ const importers = [];
4289
+ for (const other of await glob(["src/**/*.ts", "tests/**/*.ts"], {
4290
+ cwd: project.root,
4291
+ ignore: ["src/generated/**"]
4292
+ })) if (other !== path && importsOf(await readFile(abs(other), "utf8"), spec)) importers.push(other);
4293
+ if (importers.length > 0) {
4294
+ report.notes.push(`${path} was kept: ${importers.join(", ")} import it.`);
4295
+ return "kept";
4296
+ }
4297
+ return ctx.removeIfUnchanged(path);
4298
+ }
4299
+ await write(path, next);
4300
+ return "written";
4301
+ },
4147
4302
  async addDependency(name, range, { dev = false } = {}) {
4148
4303
  if (ejected.includes(name.replace(/^@tatara\//, ""))) {
4149
4304
  const note = `${name} is ejected; it was not added (its code is in src/ejected)`;
@@ -4159,9 +4314,33 @@ function createContext(project, baseline, opts = {}) {
4159
4314
  await write("package.json", `${JSON.stringify(pkg, null, " ")}\n`);
4160
4315
  }
4161
4316
  };
4317
+ return ctx;
4162
4318
  }
4163
4319
  //#endregion
4164
4320
  //#region src/commands/upgrade.ts
4321
+ async function pmInstall(project) {
4322
+ const [cmd = "pnpm", ...args] = pmCommands(project.config.packageManager).install;
4323
+ if ((await x(cmd, args, {
4324
+ throwOnError: false,
4325
+ nodeOptions: {
4326
+ cwd: project.root,
4327
+ stdio: "inherit"
4328
+ }
4329
+ })).exitCode !== 0) throw new Error(`${cmd} ${args.join(" ")} failed; fix it, then run tatara upgrade again`);
4330
+ }
4331
+ /** Sets every @tatara/* library the app depends on to the range this CLI ships with. */
4332
+ async function bumpPackages(ctx) {
4333
+ const pkg = JSON.parse(await ctx.read("package.json"));
4334
+ const known = templateVersions();
4335
+ for (const dev of [false, true]) for (const [name, range] of Object.entries((dev ? pkg.devDependencies : pkg.dependencies) ?? {})) {
4336
+ if (!name.startsWith("@tatara/") || name === "@tatara/cli" || !(name in known)) continue;
4337
+ const current = /^\^(\d+\.\d+\.\d+)$/.exec(range)?.[1];
4338
+ const next = versionOf(name);
4339
+ const shipped = /^\^(\d+\.\d+\.\d+)$/.exec(next)?.[1];
4340
+ if (!current || !shipped || compareVersions(current, shipped) >= 0) continue;
4341
+ await ctx.addDependency(name, next, { dev });
4342
+ }
4343
+ }
4165
4344
  async function biomeFormat(file, project) {
4166
4345
  const biome = join(project.root, "node_modules", ".bin", "biome");
4167
4346
  if (!existsSync(biome)) return;
@@ -4208,9 +4387,11 @@ async function upgrade(project, opts = {}) {
4208
4387
  ctx.report.ran.push(mod.id);
4209
4388
  if (ctx.report.blocked.length > blockedBefore && reached === target) reached = due.slice(0, due.indexOf(mod)).at(-1)?.version ?? from;
4210
4389
  }
4390
+ await bumpPackages(ctx);
4211
4391
  if (opts.dryRun) return ctx.report;
4212
4392
  const format = opts.formatFile ?? biomeFormat;
4213
4393
  for (const path of ctx.report.written) await format(path, project);
4394
+ await (opts.runInstall ?? pmInstall)(project);
4214
4395
  await (opts.runSync ?? sync)(project);
4215
4396
  const path = join(project.root, "tatara.json");
4216
4397
  const config = JSON.parse(await readFile(path, "utf8"));
@@ -4243,7 +4424,7 @@ function printUpgrade(report, from, to) {
4243
4424
  //#endregion
4244
4425
  //#region src/bin.ts
4245
4426
  const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
4246
- 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) => {
4427
+ 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").addOption(new Option("--framework-tests", "include tatara's own wiring tests (e2e)").hideHelp()).action(async (dir, flags) => {
4247
4428
  await newApp(dir, flags);
4248
4429
  });
4249
4430
  program.command("sync").description("Regenerate src/generated from discovered module files").option("--quiet", "no output").action(async (flags) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,15 @@
1
+ import { createAuthz } from "@tatara/authz";
2
+ import { drizzleAuthzStore } from "@tatara/authz/drizzle";
3
+ import * as tables from "#core/authz/db/authz.schema";
4
+ import { SYSTEM_ROLES } from "#core/authz/system-roles";
5
+ import { db } from "#core/db/client";
6
+ import { catalog, type PermissionKey } from "#generated/permissions";
7
+
8
+ /** The app's authorization: its tables (authz.schema.ts), the catalog from `tatara sync`, built-in roles. */
9
+ export const authz = createAuthz<PermissionKey>({
10
+ store: drizzleAuthzStore(db, tables),
11
+ catalog,
12
+ systemRoles: SYSTEM_ROLES,
13
+ });
14
+
15
+ export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -0,0 +1,14 @@
1
+ import { createAuthz } from "@tatara/authz";
2
+ import { prismaAuthzStore } from "@tatara/authz/prisma";
3
+ import { SYSTEM_ROLES } from "#core/authz/system-roles";
4
+ import { db } from "#core/db/client";
5
+ import { catalog, type PermissionKey } from "#generated/permissions";
6
+
7
+ /** The app's authorization: its tables (prisma/models/authz.prisma), the catalog from `tatara sync`, built-in roles. */
8
+ export const authz = createAuthz<PermissionKey>({
9
+ store: prismaAuthzStore(db),
10
+ catalog,
11
+ systemRoles: SYSTEM_ROLES,
12
+ });
13
+
14
+ export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -33,13 +33,13 @@
33
33
  "@types/nodemailer": "8.0.2",
34
34
  "@tatara/core": "0.1.1",
35
35
  "@tatara/http": "0.1.1",
36
- "@tatara/db": "0.1.1",
36
+ "@tatara/db": "0.2.0",
37
37
  "@tatara/testing": "0.1.1",
38
38
  "@tatara/auth": "0.1.1",
39
39
  "@tatara/keycloak": "0.1.1",
40
- "@tatara/authz": "0.1.0",
41
- "@tatara/storage": "0.1.0",
42
- "@tatara/email": "0.1.0",
40
+ "@tatara/authz": "0.2.0",
41
+ "@tatara/storage": "0.2.0",
42
+ "@tatara/email": "0.2.0",
43
43
  "@tatara/i18n": "0.1.0",
44
- "@tatara/jobs": "0.1.0"
44
+ "@tatara/jobs": "0.2.0"
45
45
  }
@@ -1,21 +0,0 @@
1
- import { z } from "zod";
2
- import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
3
- import { authz } from "#core/authz/index";
4
- import { defineCommand } from "@tatara/core";
5
-
6
- export default defineCommand({
7
- name: "authz:grant-role",
8
- description: "Assign a role (built-in key or custom role key) to a user in an org",
9
- args: z.object({
10
- user: z.string().min(1),
11
- role: z.string().min(1),
12
- org: z.string().default(DEFAULT_ORG_ID),
13
- }),
14
- async run({ user, role, org }, { logger }) {
15
- const found = (await authz.store.findRoleByKey(org, role)) ??
16
- (await authz.store.findRoleByKey(SYSTEM_ORG_ID, role));
17
- if (!found) throw new Error(`No role "${role}" (run authz:sync-permissions first for built-in roles)`);
18
- const added = await authz.assignRole(null, org, user, found.id);
19
- logger.info({ user, role, org, added }, added ? "role assigned" : "role already assigned");
20
- },
21
- });
@@ -1,11 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { authz } from "#core/authz/index";
3
-
4
- export default defineCommand({
5
- name: "authz:sync-permissions",
6
- description: "Create/update built-in roles so their grants match the code (run on every deploy)",
7
- async run(_args, { logger }) {
8
- const result = await authz.syncSystemRoles();
9
- logger.info(result, "built-in roles in sync");
10
- },
11
- });
@@ -1,10 +0,0 @@
1
- import { authz } from "#core/authz/index";
2
- import { defineSeed } from "@tatara/db";
3
-
4
- export default defineSeed({
5
- name: "authz:system-roles",
6
- order: -10,
7
- async run({ logger }) {
8
- logger.info(await authz.syncSystemRoles(), "built-in roles in sync");
9
- },
10
- });
@@ -1,9 +0,0 @@
1
- import { createAuthz } from "@tatara/authz";
2
- import { store } from "#core/authz/db/store";
3
- import { SYSTEM_ROLES } from "#core/authz/system-roles";
4
- import { catalog, type PermissionKey } from "#generated/permissions";
5
-
6
- /** The app's authorization: its store, the permission catalog from `tatara sync`, built-in roles. */
7
- export const authz = createAuthz<PermissionKey>({ store, catalog, systemRoles: SYSTEM_ROLES });
8
-
9
- export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -1,275 +0,0 @@
1
- import { and, count, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
2
- import type { OverlayEffect, Scope } from "@tatara/authz";
3
- import { SYSTEM_ORG_ID } from "@tatara/authz";
4
- import {
5
- authzAuditLog,
6
- authzMemberRoles,
7
- authzOverlays,
8
- authzRolePermissions,
9
- authzRoles,
10
- } from "#core/authz/db/authz.schema";
11
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
12
- import { db } from "#core/db/client";
13
- import { newId } from "@tatara/db";
14
-
15
- const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
16
-
17
- type RoleRecord = typeof authzRoles.$inferSelect;
18
- type OverlayRecord = typeof authzOverlays.$inferSelect;
19
-
20
- function toOverlay(row: OverlayRecord): OverlayRow {
21
- return { ...row, scope: toScope(row.scope), effect: row.effect as OverlayEffect };
22
- }
23
-
24
- async function hydrate(roles: RoleRecord[], orgId: string): Promise<RoleRow[]> {
25
- if (roles.length === 0) return [];
26
- const ids = roles.map((r) => r.id);
27
- const [grants, members] = await Promise.all([
28
- db.select().from(authzRolePermissions).where(inArray(authzRolePermissions.roleId, ids)),
29
- db
30
- .select({ roleId: authzMemberRoles.roleId, total: count() })
31
- .from(authzMemberRoles)
32
- .where(and(eq(authzMemberRoles.orgId, orgId), inArray(authzMemberRoles.roleId, ids)))
33
- .groupBy(authzMemberRoles.roleId),
34
- ]);
35
- return roles.map((role) => ({
36
- id: role.id,
37
- orgId: role.orgId,
38
- key: role.key,
39
- name: role.name,
40
- description: role.description,
41
- isSystem: role.isSystem,
42
- grants: grants
43
- .filter((g) => g.roleId === role.id)
44
- .map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
45
- memberCount: members.find((m) => m.roleId === role.id)?.total ?? 0,
46
- }));
47
- }
48
-
49
- const visibleTo = (orgId: string) => or(eq(authzRoles.orgId, SYSTEM_ORG_ID), eq(authzRoles.orgId, orgId));
50
-
51
- async function insertGrants(roleId: string, grants: PermissionGrant[]) {
52
- if (grants.length === 0) return;
53
- await db.insert(authzRolePermissions).values(
54
- grants.map((g) => ({
55
- id: newId(),
56
- roleId,
57
- resource: g.resource,
58
- action: g.action,
59
- scope: g.scope ?? "",
60
- })),
61
- );
62
- }
63
-
64
- export const store: AuthzStore = {
65
- async roleGrants(orgId, userId, impliedRoleKeys) {
66
- const assigned = await db
67
- .select({ roleId: authzMemberRoles.roleId })
68
- .from(authzMemberRoles)
69
- .where(and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId)));
70
- const implied = impliedRoleKeys.length
71
- ? await db
72
- .select({ id: authzRoles.id })
73
- .from(authzRoles)
74
- .where(and(eq(authzRoles.orgId, SYSTEM_ORG_ID), inArray(authzRoles.key, impliedRoleKeys)))
75
- : [];
76
- const roleIds = [...new Set([...assigned.map((r) => r.roleId), ...implied.map((r) => r.id)])];
77
- if (roleIds.length === 0) return { roles: [], grants: [] };
78
- const [roles, grants] = await Promise.all([
79
- db.select({ key: authzRoles.key }).from(authzRoles).where(inArray(authzRoles.id, roleIds)),
80
- db.select().from(authzRolePermissions).where(inArray(authzRolePermissions.roleId, roleIds)),
81
- ]);
82
- return {
83
- roles: roles.map((r) => r.key),
84
- grants: grants.map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
85
- };
86
- },
87
-
88
- async activeOverlays(orgId, userId, now) {
89
- const rows = await db
90
- .select()
91
- .from(authzOverlays)
92
- .where(
93
- and(
94
- eq(authzOverlays.orgId, orgId),
95
- eq(authzOverlays.userId, userId),
96
- or(isNull(authzOverlays.expiresAt), gt(authzOverlays.expiresAt, now)),
97
- ),
98
- );
99
- return rows.map(toOverlay);
100
- },
101
-
102
- async listRoles(orgId) {
103
- const roles = await db.select().from(authzRoles).where(visibleTo(orgId)).orderBy(authzRoles.key);
104
- return hydrate(roles, orgId);
105
- },
106
-
107
- async findRole(orgId, roleId) {
108
- const roles = await db
109
- .select()
110
- .from(authzRoles)
111
- .where(and(eq(authzRoles.id, roleId), visibleTo(orgId)));
112
- return (await hydrate(roles, orgId))[0] ?? null;
113
- },
114
-
115
- async findRoleByKey(orgId, key) {
116
- const roles = await db
117
- .select()
118
- .from(authzRoles)
119
- .where(and(eq(authzRoles.key, key), eq(authzRoles.orgId, orgId)));
120
- return (await hydrate(roles, orgId))[0] ?? null;
121
- },
122
-
123
- async createRole(input) {
124
- const id = newId();
125
- await db.insert(authzRoles).values({
126
- id,
127
- orgId: input.orgId,
128
- key: input.key,
129
- name: input.name,
130
- description: input.description,
131
- isSystem: input.isSystem,
132
- });
133
- await insertGrants(id, input.grants);
134
- return id;
135
- },
136
-
137
- async updateRole(roleId, input) {
138
- const fields = {
139
- ...(input.name === undefined ? {} : { name: input.name }),
140
- ...(input.description === undefined ? {} : { description: input.description }),
141
- };
142
- await db
143
- .update(authzRoles)
144
- .set({ ...fields, updatedAt: new Date() })
145
- .where(eq(authzRoles.id, roleId));
146
- if (input.grants) {
147
- await db.delete(authzRolePermissions).where(eq(authzRolePermissions.roleId, roleId));
148
- await insertGrants(roleId, input.grants);
149
- }
150
- },
151
-
152
- async deleteRole(roleId) {
153
- await db.delete(authzRoles).where(eq(authzRoles.id, roleId));
154
- },
155
-
156
- async memberRoles(orgId, userId) {
157
- const assigned = await db
158
- .select({ roleId: authzMemberRoles.roleId })
159
- .from(authzMemberRoles)
160
- .where(and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId)));
161
- if (assigned.length === 0) return [];
162
- const roles = await db
163
- .select()
164
- .from(authzRoles)
165
- .where(inArray(authzRoles.id, assigned.map((a) => a.roleId)));
166
- return hydrate(roles, orgId);
167
- },
168
-
169
- async assignRole(orgId, userId, roleId, by) {
170
- const existing = await db
171
- .select({ id: authzMemberRoles.id })
172
- .from(authzMemberRoles)
173
- .where(
174
- and(
175
- eq(authzMemberRoles.orgId, orgId),
176
- eq(authzMemberRoles.userId, userId),
177
- eq(authzMemberRoles.roleId, roleId),
178
- ),
179
- );
180
- if (existing.length > 0) return false;
181
- await db.insert(authzMemberRoles).values({ id: newId(), orgId, userId, roleId, assignedBy: by });
182
- return true;
183
- },
184
-
185
- async revokeRole(orgId, userId, roleId) {
186
- const where = and(
187
- eq(authzMemberRoles.orgId, orgId),
188
- eq(authzMemberRoles.userId, userId),
189
- eq(authzMemberRoles.roleId, roleId),
190
- );
191
- const existing = await db.select({ id: authzMemberRoles.id }).from(authzMemberRoles).where(where);
192
- if (existing.length === 0) return false;
193
- await db.delete(authzMemberRoles).where(where);
194
- return true;
195
- },
196
-
197
- async removeMember(orgId, userId) {
198
- const roleWhere = and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId));
199
- const overlayWhere = and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId));
200
- // count first: MySQL has no RETURNING
201
- return db.transaction(async (tx) => {
202
- const [roles] = await tx.select({ n: count() }).from(authzMemberRoles).where(roleWhere);
203
- const [overlays] = await tx.select({ n: count() }).from(authzOverlays).where(overlayWhere);
204
- await tx.delete(authzMemberRoles).where(roleWhere);
205
- await tx.delete(authzOverlays).where(overlayWhere);
206
- return { roles: roles?.n ?? 0, overlays: overlays?.n ?? 0 };
207
- });
208
- },
209
-
210
- async listOverlays(orgId, userId) {
211
- const rows = await db
212
- .select()
213
- .from(authzOverlays)
214
- .where(and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId)))
215
- .orderBy(desc(authzOverlays.createdAt));
216
- return rows.map(toOverlay);
217
- },
218
-
219
- async addOverlay(input) {
220
- const id = newId();
221
- const scope = input.scope ?? "";
222
- await db
223
- .delete(authzOverlays)
224
- .where(
225
- and(
226
- eq(authzOverlays.orgId, input.orgId),
227
- eq(authzOverlays.userId, input.userId),
228
- eq(authzOverlays.resource, input.resource),
229
- eq(authzOverlays.action, input.action),
230
- eq(authzOverlays.scope, scope),
231
- eq(authzOverlays.effect, input.effect),
232
- ),
233
- );
234
- await db.insert(authzOverlays).values({ ...input, id, scope });
235
- return id;
236
- },
237
-
238
- async removeOverlay(orgId, userId, overlayId) {
239
- const where = and(
240
- eq(authzOverlays.orgId, orgId),
241
- eq(authzOverlays.userId, userId),
242
- eq(authzOverlays.id, overlayId),
243
- );
244
- const [row] = await db.select().from(authzOverlays).where(where);
245
- if (!row) return null;
246
- await db.delete(authzOverlays).where(where);
247
- return toOverlay(row);
248
- },
249
-
250
- async writeAudit(input) {
251
- await db.insert(authzAuditLog).values({
252
- id: newId(),
253
- orgId: input.orgId,
254
- actorId: input.actorId,
255
- targetUserId: input.targetUserId,
256
- changeType: input.changeType,
257
- before: input.before === undefined ? null : JSON.stringify(input.before),
258
- after: input.after === undefined ? null : JSON.stringify(input.after),
259
- });
260
- },
261
-
262
- async listAudit(orgId, limit) {
263
- const rows = await db
264
- .select()
265
- .from(authzAuditLog)
266
- .where(eq(authzAuditLog.orgId, orgId))
267
- .orderBy(desc(authzAuditLog.id))
268
- .limit(limit);
269
- return rows.map((r) => ({
270
- ...r,
271
- before: r.before ? (JSON.parse(r.before) as unknown) : null,
272
- after: r.after ? (JSON.parse(r.after) as unknown) : null,
273
- }));
274
- },
275
- };
@@ -1,236 +0,0 @@
1
- import type { OverlayEffect, Scope } from "@tatara/authz";
2
- import { SYSTEM_ORG_ID } from "@tatara/authz";
3
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
4
- import { db } from "#core/db/client";
5
- import { newId } from "@tatara/db";
6
-
7
- const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
8
-
9
- interface OverlayRecord {
10
- id: string;
11
- orgId: string;
12
- userId: string;
13
- resource: string;
14
- action: string;
15
- scope: string;
16
- effect: string;
17
- grantedBy: string | null;
18
- reason: string | null;
19
- expiresAt: Date | null;
20
- createdAt: Date;
21
- }
22
-
23
- function toOverlay(row: OverlayRecord): OverlayRow {
24
- return { ...row, scope: toScope(row.scope), effect: row.effect as OverlayEffect };
25
- }
26
-
27
- const roleInclude = (orgId: string) => ({
28
- permissions: true,
29
- _count: { select: { members: { where: { orgId } } } },
30
- });
31
-
32
- interface RoleRecord {
33
- id: string;
34
- orgId: string;
35
- key: string;
36
- name: string;
37
- description: string | null;
38
- isSystem: boolean;
39
- permissions: { resource: string; action: string; scope: string }[];
40
- _count: { members: number };
41
- }
42
-
43
- function toRole(role: RoleRecord): RoleRow {
44
- return {
45
- id: role.id,
46
- orgId: role.orgId,
47
- key: role.key,
48
- name: role.name,
49
- description: role.description,
50
- isSystem: role.isSystem,
51
- grants: role.permissions.map((g) => ({
52
- resource: g.resource,
53
- action: g.action,
54
- scope: toScope(g.scope),
55
- })),
56
- memberCount: role._count.members,
57
- };
58
- }
59
-
60
- const grantRows = (grants: PermissionGrant[]) =>
61
- grants.map((g) => ({ id: newId(), resource: g.resource, action: g.action, scope: g.scope ?? "" }));
62
-
63
- export const store: AuthzStore = {
64
- async roleGrants(orgId, userId, impliedRoleKeys) {
65
- const roles = await db.authzRole.findMany({
66
- where: {
67
- OR: [
68
- { members: { some: { orgId, userId } } },
69
- ...(impliedRoleKeys.length
70
- ? [{ orgId: SYSTEM_ORG_ID, key: { in: impliedRoleKeys } }]
71
- : []),
72
- ],
73
- },
74
- include: { permissions: true },
75
- });
76
- return {
77
- roles: roles.map((r) => r.key),
78
- grants: roles.flatMap((r) =>
79
- r.permissions.map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
80
- ),
81
- };
82
- },
83
-
84
- async activeOverlays(orgId, userId, now) {
85
- const rows = await db.authzOverlay.findMany({
86
- where: { orgId, userId, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
87
- });
88
- return rows.map(toOverlay);
89
- },
90
-
91
- async listRoles(orgId) {
92
- const roles = await db.authzRole.findMany({
93
- where: { orgId: { in: [SYSTEM_ORG_ID, orgId] } },
94
- include: roleInclude(orgId),
95
- orderBy: { key: "asc" },
96
- });
97
- return roles.map(toRole);
98
- },
99
-
100
- async findRole(orgId, roleId) {
101
- const role = await db.authzRole.findFirst({
102
- where: { id: roleId, orgId: { in: [SYSTEM_ORG_ID, orgId] } },
103
- include: roleInclude(orgId),
104
- });
105
- return role ? toRole(role) : null;
106
- },
107
-
108
- async findRoleByKey(orgId, key) {
109
- const role = await db.authzRole.findFirst({ where: { key, orgId }, include: roleInclude(orgId) });
110
- return role ? toRole(role) : null;
111
- },
112
-
113
- async createRole(input) {
114
- const id = newId();
115
- await db.authzRole.create({
116
- data: {
117
- id,
118
- orgId: input.orgId,
119
- key: input.key,
120
- name: input.name,
121
- description: input.description,
122
- isSystem: input.isSystem,
123
- permissions: { create: grantRows(input.grants) },
124
- },
125
- });
126
- return id;
127
- },
128
-
129
- async updateRole(roleId, input) {
130
- await db.$transaction(async (tx) => {
131
- await tx.authzRole.update({
132
- where: { id: roleId },
133
- data: {
134
- ...(input.name === undefined ? {} : { name: input.name }),
135
- ...(input.description === undefined ? {} : { description: input.description }),
136
- },
137
- });
138
- if (input.grants) {
139
- await tx.authzRolePermission.deleteMany({ where: { roleId } });
140
- await tx.authzRolePermission.createMany({
141
- data: grantRows(input.grants).map((g) => ({ ...g, roleId })),
142
- });
143
- }
144
- });
145
- },
146
-
147
- async deleteRole(roleId) {
148
- await db.authzRole.delete({ where: { id: roleId } });
149
- },
150
-
151
- async memberRoles(orgId, userId) {
152
- const roles = await db.authzRole.findMany({
153
- where: { members: { some: { orgId, userId } } },
154
- include: roleInclude(orgId),
155
- });
156
- return roles.map(toRole);
157
- },
158
-
159
- async assignRole(orgId, userId, roleId, by) {
160
- const existing = await db.authzMemberRole.findFirst({ where: { orgId, userId, roleId } });
161
- if (existing) return false;
162
- await db.authzMemberRole.create({ data: { id: newId(), orgId, userId, roleId, assignedBy: by } });
163
- return true;
164
- },
165
-
166
- async revokeRole(orgId, userId, roleId) {
167
- const { count } = await db.authzMemberRole.deleteMany({ where: { orgId, userId, roleId } });
168
- return count > 0;
169
- },
170
-
171
- async removeMember(orgId, userId) {
172
- const [roles, overlays] = await db.$transaction([
173
- db.authzMemberRole.deleteMany({ where: { orgId, userId } }),
174
- db.authzOverlay.deleteMany({ where: { orgId, userId } }),
175
- ]);
176
- return { roles: roles.count, overlays: overlays.count };
177
- },
178
- async listOverlays(orgId, userId) {
179
- const rows = await db.authzOverlay.findMany({
180
- where: { orgId, userId },
181
- orderBy: { createdAt: "desc" },
182
- });
183
- return rows.map(toOverlay);
184
- },
185
-
186
- async addOverlay(input) {
187
- const id = newId();
188
- const scope = input.scope ?? "";
189
- await db.authzOverlay.deleteMany({
190
- where: {
191
- orgId: input.orgId,
192
- userId: input.userId,
193
- resource: input.resource,
194
- action: input.action,
195
- scope,
196
- effect: input.effect,
197
- },
198
- });
199
- await db.authzOverlay.create({ data: { ...input, id, scope } });
200
- return id;
201
- },
202
-
203
- async removeOverlay(orgId, userId, overlayId) {
204
- const row = await db.authzOverlay.findFirst({ where: { id: overlayId, orgId, userId } });
205
- if (!row) return null;
206
- await db.authzOverlay.deleteMany({ where: { id: overlayId, orgId, userId } });
207
- return toOverlay(row);
208
- },
209
-
210
- async writeAudit(input) {
211
- await db.authzAuditLog.create({
212
- data: {
213
- id: newId(),
214
- orgId: input.orgId,
215
- actorId: input.actorId,
216
- targetUserId: input.targetUserId,
217
- changeType: input.changeType,
218
- before: input.before === undefined ? null : JSON.stringify(input.before),
219
- after: input.after === undefined ? null : JSON.stringify(input.after),
220
- },
221
- });
222
- },
223
-
224
- async listAudit(orgId, limit) {
225
- const rows = await db.authzAuditLog.findMany({
226
- where: { orgId },
227
- orderBy: { id: "desc" },
228
- take: limit,
229
- });
230
- return rows.map((r) => ({
231
- ...r,
232
- before: r.before ? (JSON.parse(r.before) as unknown) : null,
233
- after: r.after ? (JSON.parse(r.after) as unknown) : null,
234
- }));
235
- },
236
- };
@@ -1,12 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { env } from "#core/config/env";
3
- import { migrateDatabase } from "#core/db/migrate";
4
-
5
- export default defineCommand({
6
- name: "db:migrate",
7
- description: "Apply pending database migrations",
8
- async run(_args, { logger }) {
9
- await migrateDatabase(env.DATABASE_URL);
10
- logger.info("migrations applied");
11
- },
12
- });
@@ -1,19 +0,0 @@
1
- import { z } from "zod";
2
- import { defineCommand, flag } from "@tatara/core";
3
- import { env } from "#core/config/env";
4
- import { dropAllTables } from "#core/db/admin";
5
- import { migrateDatabase } from "#core/db/migrate";
6
-
7
- export default defineCommand({
8
- name: "db:reset",
9
- description: "Drop every table and re-apply migrations (refuses to run in production)",
10
- args: z.object({ force: flag() }),
11
- async run({ force }, { logger }) {
12
- if (env.NODE_ENV === "production" && !force) {
13
- throw new Error("db:reset refuses to run with NODE_ENV=production (pass --force to override)");
14
- }
15
- await dropAllTables(env.DATABASE_URL);
16
- await migrateDatabase(env.DATABASE_URL);
17
- logger.info("database reset");
18
- },
19
- });
@@ -1,13 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { seeds } from "#generated/seeds";
3
-
4
- export default defineCommand({
5
- name: "db:seed",
6
- description: "Run every idempotent seed (core first, then modules)",
7
- async run(_args, { logger }) {
8
- for (const seed of [...seeds].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))) {
9
- await seed.run({ logger: logger.child({ seed: seed.name }) });
10
- logger.info({ seed: seed.name }, "seeded");
11
- }
12
- },
13
- });
@@ -1,7 +0,0 @@
1
- import { expect, it } from "vitest";
2
- import { parseFlags } from "@tatara/core";
3
- import reset from "#core/db/commands/reset";
4
-
5
- it("does not treat --force false as forcing db:reset", () => {
6
- expect(reset.args?.parse(parseFlags(["--force", "false"]))).toEqual({ force: false });
7
- });
@@ -1,13 +0,0 @@
1
- import { z } from "zod";
2
- import { defineCommand } from "@tatara/core";
3
- import { sendEmail } from "#core/email/send";
4
-
5
- export default defineCommand({
6
- name: "email:send-test",
7
- description: "Send a test email (see it in Mailpit locally)",
8
- args: z.object({ to: z.email().default("dev@example.com") }),
9
- async run({ to }, { logger }) {
10
- await sendEmail({ to, subject: "Test email", text: "It works." });
11
- logger.info({ to }, "test email sent");
12
- },
13
- });
@@ -1,13 +0,0 @@
1
- import { z } from "zod";
2
- import { defineCommand } from "@tatara/core";
3
- import { enqueue } from "#core/jobs/index";
4
-
5
- export default defineCommand({
6
- name: "jobs:enqueue",
7
- description: "Enqueue a job by topic with a JSON payload (for development and debugging)",
8
- args: z.object({ topic: z.string().min(1), data: z.string().default("{}") }),
9
- async run({ topic, data }, { logger }) {
10
- const id = await enqueue(topic, JSON.parse(data) as unknown);
11
- logger.info({ topic, id }, "job enqueued");
12
- },
13
- });
@@ -1,18 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { env } from "#core/config/env";
3
- import { ensureBucket } from "@tatara/storage/s3";
4
- import { s3, storage } from "#core/storage/index";
5
-
6
- export default defineCommand({
7
- name: "storage:check",
8
- description: "Create the bucket if needed, then write, read and delete a probe object",
9
- async run(_args, { logger }) {
10
- if (env.STORAGE_DRIVER === "s3" && (await ensureBucket(s3, env.S3_BUCKET))) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
11
- const key = `tatara-check/${Date.now()}.txt`;
12
- await storage.put(key, "ok", "text/plain");
13
- const body = await storage.get(key);
14
- await storage.delete(key);
15
- if (new TextDecoder().decode(body ?? new Uint8Array()) !== "ok") throw new Error("storage round-trip failed");
16
- logger.info({ driver: env.STORAGE_DRIVER }, "storage OK");
17
- },
18
- });